Skip to main content

mz_storage_types/
connections.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Connection types.
11
12use std::borrow::Cow;
13use std::collections::{BTreeMap, BTreeSet};
14use std::fmt;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use std::time::SystemTime;
18
19use anyhow::{Context, anyhow};
20use async_trait::async_trait;
21use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
22use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
23use aws_sigv4::sign::v4;
24// Aliased to avoid colliding with `mz_ccsr::tls::Identity`.
25use aws_smithy_runtime_api::client::identity::Identity as AwsIdentity;
26use base64::Engine;
27use http::{HeaderMap, HeaderName, HeaderValue};
28use iceberg::Catalog;
29use iceberg::CatalogBuilder;
30use iceberg::TableIdent;
31use iceberg::io::{
32    GCS_CREDENTIALS_JSON, GCS_DISABLE_CONFIG_LOAD, GCS_DISABLE_VM_METADATA, GCS_USER_PROJECT,
33    S3_ACCESS_KEY_ID, S3_DISABLE_EC2_METADATA, S3_REGION, S3_SECRET_ACCESS_KEY,
34};
35use iceberg_catalog_rest::{
36    OAuth2TokenProvider, REST_CATALOG_PROP_URI, REST_CATALOG_PROP_WAREHOUSE, RequestAuthenticator,
37    RestCatalogBuilder, TokenProvider,
38};
39use iceberg_storage_opendal::{
40    AwsCredential, CustomAwsCredentialLoader, CustomAzdlsCredentialLoader,
41    CustomGcsCredentialLoader, OpenDalStorageFactory, ProvideCredential,
42};
43use itertools::Itertools;
44use mz_ccsr::tls::{Certificate, Identity};
45use mz_cloud_resources::{AwsExternalIdPrefix, CloudResourceReader, vpc_endpoint_host};
46use mz_dyncfg::ConfigSet;
47use mz_kafka_util::client::{
48    BrokerAddr, BrokerRewrite, HostMappingRules, MzClientContext, MzKafkaError, TunnelConfig,
49    TunnelingClientContext,
50};
51use mz_mysql_util::{MySqlConn, MySqlError};
52use mz_ore::assert_none;
53use mz_ore::error::ErrorExt;
54use mz_ore::future::{InTask, OreFutureExt};
55use mz_ore::netio::resolve_address;
56use mz_ore::num::NonNeg;
57use mz_ore::str::StrExt;
58use mz_repr::{CatalogItemId, GlobalId};
59use mz_secrets::SecretsReader;
60use mz_sql_parser::ast::ConnectionRulePattern;
61use mz_ssh_util::keys::SshKeyPair;
62use mz_ssh_util::tunnel::SshTunnelConfig;
63use mz_ssh_util::tunnel_manager::{ManagedSshTunnelHandle, SshTunnelManager};
64use mz_tracing::CloneableEnvFilter;
65use rdkafka::ClientContext;
66use rdkafka::config::FromClientConfigAndContext;
67use rdkafka::consumer::{BaseConsumer, Consumer};
68use regex::Regex;
69use reqsign_core::time::Timestamp;
70use reqwest_0_12::Request;
71use serde::{Deserialize, Deserializer, Serialize};
72use tokio::net;
73use tokio::runtime::Handle;
74use tokio_postgres::config::SslMode;
75use tracing::{debug, info, warn};
76use url::Url;
77
78use crate::AlterCompatible;
79use crate::configuration::StorageConfiguration;
80use crate::connections::aws::{
81    AwsAuth, AwsConnection, AwsConnectionReference, AwsConnectionValidationError,
82};
83use crate::connections::gcp::{GcpConnectionReference, GcpTokenProvider};
84use crate::connections::string_or_secret::StringOrSecret;
85use crate::controller::AlterError;
86use crate::dyncfgs::{
87    ENFORCE_EXTERNAL_ADDRESSES, KAFKA_CLIENT_ID_ENRICHMENT_RULES,
88    KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM, KAFKA_RECONNECT_BACKOFF,
89    KAFKA_RECONNECT_BACKOFF_MAX, KAFKA_RETRY_BACKOFF, KAFKA_RETRY_BACKOFF_MAX,
90};
91use crate::errors::{ContextCreationError, CsrConnectError};
92
93pub mod aws;
94pub mod gcp;
95mod iceberg_credentials;
96pub mod inline;
97pub mod string_or_secret;
98
99/// The OAuth2 form field naming the scopes a token is requested for.
100///
101/// Materialize drives the OAuth2 exchange itself rather than through the `credential`,
102/// `oauth2-server-uri`, and `scope` catalog properties, so that one token object serves both
103/// catalog requests and storage-credential refreshes.
104const OAUTH2_PARAM_SCOPE: &str = "scope";
105
106const REST_CATALOG_PROP_OAUTH2_SERVER_URI: &str = "oauth2-server-uri";
107/// The prefix marking a catalog property that `iceberg-rust` turns into a header on every REST
108/// request, the same convention the Iceberg Java client uses.
109const REST_CATALOG_HEADER_PROP_PREFIX: &str = "header.";
110/// Requests catalog-vended storage credentials, carried as a header by way of
111/// [`REST_CATALOG_HEADER_PROP_PREFIX`].
112const REST_CATALOG_PROP_ACCESS_DELEGATION: &str = "header.X-Iceberg-Access-Delegation";
113
114/// A credential loader that wraps an aws-sdk-rust credentials provider for use with
115/// iceberg/OpenDAL. This allows us to provide refreshable credentials from the AWS SDK
116/// credential chain (including the full assume role chain) to OpenDAL's S3 implementation.
117///
118/// We use this instead of OpenDAL's built-in assume role support because Materialize
119/// has a runtime-defined credential chain (ambient → jump role → user role with external ID)
120/// that can't be expressed via OpenDAL's static configuration properties.
121#[derive(Debug)]
122struct AwsSdkCredentialLoader {
123    /// The underlying AWS SDK credentials provider. For assume role auth, this provider
124    /// already handles the full chain: ambient creds -> jump role -> user role.
125    provider: SharedCredentialsProvider,
126}
127
128impl AwsSdkCredentialLoader {
129    fn new(provider: SharedCredentialsProvider) -> Self {
130        Self { provider }
131    }
132}
133
134impl ProvideCredential for AwsSdkCredentialLoader {
135    type Credential = AwsCredential;
136
137    async fn provide_credential(
138        &self,
139        _ctx: &reqsign_core::Context,
140    ) -> reqsign_core::Result<Option<Self::Credential>> {
141        let creds = self.provider.provide_credentials().await.map_err(|e| {
142            warn!(
143                error = %e.display_with_causes(),
144                "failed to load AWS credentials for Iceberg FileIO from SDK provider"
145            );
146            reqsign_core::Error::credential_invalid(
147                "failed to load AWS credentials from SDK provider for Iceberg FileIO \
148                 (credential source may be temporarily unavailable)",
149            )
150            .with_source(e)
151        })?;
152
153        // Propagate the SDK's expiry whenever it reports one. reqsign treats a `None` expiry as
154        // "valid forever", so dropping it would leave OpenDAL signing with stale assume-role
155        // credentials rather than asking us for fresh ones.
156        let expires_in = creds.expiry().map(aws_expiry_to_timestamp).transpose()?;
157
158        Ok(Some(AwsCredential {
159            access_key_id: creds.access_key_id().to_string(),
160            secret_access_key: creds.secret_access_key().to_string(),
161            session_token: creds.session_token().map(|s| s.to_string()),
162            expires_in,
163        }))
164    }
165}
166
167/// Converts an AWS SDK credential expiry into reqsign's [`Timestamp`].
168///
169/// Both failure modes require a nonsensical expiry (before the Unix epoch, or beyond year
170/// 292278994), so they are reported as errors rather than silently dropped, which would make the
171/// credential look non-expiring.
172fn aws_expiry_to_timestamp(expiry: SystemTime) -> reqsign_core::Result<Timestamp> {
173    let millis = expiry
174        .duration_since(SystemTime::UNIX_EPOCH)
175        .map_err(|e| {
176            reqsign_core::Error::unexpected("AWS credential expiry precedes the Unix epoch")
177                .with_source(e)
178        })?
179        .as_millis();
180    let millis = i64::try_from(millis).map_err(|e| {
181        reqsign_core::Error::unexpected("AWS credential expiry overflows a millisecond timestamp")
182            .with_source(e)
183    })?;
184    Timestamp::from_millisecond(millis)
185}
186
187/// Signs each outgoing REST-catalog request with AWS SigV4.
188///
189/// Holds a [`SharedCredentialsProvider`] (not static `Credentials`) so each
190/// request signs with refreshable creds from Materialize's chain
191/// (ambient -> jump role -> user role w/ external ID).
192struct Sigv4Authenticator {
193    provider: SharedCredentialsProvider,
194    region: String,
195    /// The AWS signing name. `"s3tables"` for AWS S3 Tables REST catalog.
196    signing_name: String,
197}
198
199impl std::fmt::Debug for Sigv4Authenticator {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.debug_struct("Sigv4Authenticator")
202            .field("region", &self.region)
203            .field("signing_name", &self.signing_name)
204            .finish_non_exhaustive()
205    }
206}
207
208fn sigv4_err(e: impl Into<anyhow::Error>) -> iceberg::Error {
209    iceberg::Error::new(iceberg::ErrorKind::DataInvalid, "AWS SigV4").with_source(e)
210}
211
212#[async_trait]
213impl RequestAuthenticator for Sigv4Authenticator {
214    async fn authenticate_request(&self, req: &mut Request) -> iceberg::Result<()> {
215        let creds = self
216            .provider
217            .provide_credentials()
218            .await
219            .map_err(sigv4_err)?;
220        let identity: AwsIdentity = creds.into();
221        let params = v4::SigningParams::builder()
222            .identity(&identity)
223            .region(&self.region)
224            .name(&self.signing_name)
225            .time(SystemTime::now())
226            .settings(SigningSettings::default())
227            .build()
228            .map_err(sigv4_err)?
229            .into();
230        let body: &[u8] = req
231            .body()
232            .map(|b| match b.as_bytes() {
233                Some(b) => Ok(b),
234                None => Err(iceberg::Error::new(
235                    iceberg::ErrorKind::FeatureUnsupported,
236                    "SigV4 Authenticator cannot sign a streaming request body.",
237                )),
238            })
239            .transpose()?
240            .unwrap_or_default();
241        let headers = req
242            .headers()
243            .iter()
244            .map(|(k, v)| {
245                Ok((
246                    k.as_str(),
247                    v.to_str().map_err(|_| {
248                        iceberg::Error::new(
249                            iceberg::ErrorKind::DataInvalid,
250                            format!("header '{}' value is not all visible ASCII", k),
251                        )
252                    })?,
253                ))
254            })
255            .collect::<iceberg::Result<Vec<(&str, &str)>>>()?;
256        let signable = SignableRequest::new(
257            req.method().as_str(),
258            req.url().as_str(),
259            headers.into_iter(),
260            SignableBody::Bytes(body),
261        )
262        .map_err(sigv4_err)?;
263        let (instructions, _sig) = sign(signable, &params).map_err(sigv4_err)?.into_parts();
264        let (new_headers, new_query) = instructions.into_parts();
265        for header in new_headers {
266            let mut value = HeaderValue::from_str(header.value()).map_err(sigv4_err)?;
267            value.set_sensitive(header.sensitive());
268            req.headers_mut()
269                .insert(HeaderName::from_static(header.name()), value);
270        }
271        if !new_query.is_empty() {
272            let url = req.url_mut();
273            let mut pairs = url.query_pairs_mut();
274            for (name, value) in new_query {
275                pairs.append_pair(name, &value);
276            }
277        }
278        Ok(())
279    }
280
281    // SigV4 is stateless: nothing to cache, invalidate, or refresh.
282    async fn invalidate_cache(&self) -> iceberg::Result<()> {
283        Ok(())
284    }
285    async fn regenerate_cache(&self) -> iceberg::Result<()> {
286        Ok(())
287    }
288}
289
290/// An extension trait for [`SecretsReader`]
291#[async_trait::async_trait]
292trait SecretsReaderExt {
293    /// `SecretsReader::read`, but optionally run in a task.
294    async fn read_in_task_if(
295        &self,
296        in_task: InTask,
297        id: CatalogItemId,
298    ) -> Result<Vec<u8>, anyhow::Error>;
299
300    /// `SecretsReader::read_string`, but optionally run in a task.
301    async fn read_string_in_task_if(
302        &self,
303        in_task: InTask,
304        id: CatalogItemId,
305    ) -> Result<String, anyhow::Error>;
306}
307
308#[async_trait::async_trait]
309impl SecretsReaderExt for Arc<dyn SecretsReader> {
310    async fn read_in_task_if(
311        &self,
312        in_task: InTask,
313        id: CatalogItemId,
314    ) -> Result<Vec<u8>, anyhow::Error> {
315        let sr = Arc::clone(self);
316        async move { sr.read(id).await }
317            .run_in_task_if(in_task, || "secrets_reader_read".to_string())
318            .await
319    }
320    async fn read_string_in_task_if(
321        &self,
322        in_task: InTask,
323        id: CatalogItemId,
324    ) -> Result<String, anyhow::Error> {
325        let sr = Arc::clone(self);
326        async move { sr.read_string(id).await }
327            .run_in_task_if(in_task, || "secrets_reader_read".to_string())
328            .await
329    }
330}
331
332/// Extra context to pass through when instantiating a connection for a source
333/// or sink.
334///
335/// Should be kept cheaply cloneable.
336#[derive(Debug, Clone)]
337pub struct ConnectionContext {
338    /// An opaque identifier for the environment in which this process is
339    /// running.
340    ///
341    /// The storage layer is intentionally unaware of the structure within this
342    /// identifier. Higher layers of the stack can make use of that structure,
343    /// but the storage layer should be oblivious to it.
344    pub environment_id: String,
345    /// The level for librdkafka's logs.
346    pub librdkafka_log_level: tracing::Level,
347    /// A prefix for an external ID to use for all AWS AssumeRole operations.
348    pub aws_external_id_prefix: Option<AwsExternalIdPrefix>,
349    /// The ARN for a Materialize-controlled role to assume before assuming
350    /// a customer's requested role for an AWS connection.
351    pub aws_connection_role_arn: Option<String>,
352    /// A secrets reader.
353    pub secrets_reader: Arc<dyn SecretsReader>,
354    /// A cloud resource reader, if supported in this configuration.
355    pub cloud_resource_reader: Option<Arc<dyn CloudResourceReader>>,
356    /// A manager for SSH tunnels.
357    pub ssh_tunnel_manager: SshTunnelManager,
358}
359
360impl ConnectionContext {
361    /// Constructs a new connection context from command line arguments.
362    ///
363    /// **WARNING:** it is critical for security that the `aws_external_id` be
364    /// provided by the operator of the Materialize service (i.e., via a CLI
365    /// argument or environment variable) and not the end user of Materialize
366    /// (e.g., via a configuration option in a SQL statement). See
367    /// [`AwsExternalIdPrefix`] for details.
368    pub fn from_cli_args(
369        environment_id: String,
370        startup_log_level: &CloneableEnvFilter,
371        aws_external_id_prefix: Option<AwsExternalIdPrefix>,
372        aws_connection_role_arn: Option<String>,
373        secrets_reader: Arc<dyn SecretsReader>,
374        cloud_resource_reader: Option<Arc<dyn CloudResourceReader>>,
375    ) -> ConnectionContext {
376        ConnectionContext {
377            environment_id,
378            librdkafka_log_level: mz_ore::tracing::crate_level(
379                &startup_log_level.clone().into(),
380                "librdkafka",
381            ),
382            aws_external_id_prefix,
383            aws_connection_role_arn,
384            secrets_reader,
385            cloud_resource_reader,
386            ssh_tunnel_manager: SshTunnelManager::default(),
387        }
388    }
389
390    /// Constructs a new connection context for usage in tests.
391    pub fn for_tests(secrets_reader: Arc<dyn SecretsReader>) -> ConnectionContext {
392        ConnectionContext {
393            environment_id: "test-environment-id".into(),
394            librdkafka_log_level: tracing::Level::INFO,
395            aws_external_id_prefix: Some(
396                AwsExternalIdPrefix::new_from_cli_argument_or_environment_variable(
397                    "test-aws-external-id-prefix",
398                )
399                .expect("infallible"),
400            ),
401            aws_connection_role_arn: Some(
402                "arn:aws:iam::123456789000:role/MaterializeConnection".into(),
403            ),
404            secrets_reader,
405            cloud_resource_reader: None,
406            ssh_tunnel_manager: SshTunnelManager::default(),
407        }
408    }
409}
410
411#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
412pub enum Connection<C: ConnectionAccess = InlinedConnection> {
413    Kafka(KafkaConnection<C>),
414    Csr(CsrConnection<C>),
415    GlueSchemaRegistry(GlueSchemaRegistryConnection<C>),
416    Postgres(PostgresConnection<C>),
417    Ssh(SshConnection),
418    Aws(AwsConnection),
419    AwsPrivatelink(AwsPrivatelinkConnection),
420    Gcp(gcp::GcpConnection),
421    MySql(MySqlConnection<C>),
422    SqlServer(SqlServerConnectionDetails<C>),
423    IcebergCatalog(IcebergCatalogConnection<C>),
424}
425
426impl<R: ConnectionResolver> IntoInlineConnection<Connection, R>
427    for Connection<ReferencedConnection>
428{
429    fn into_inline_connection(self, r: R) -> Connection {
430        match self {
431            Connection::Kafka(kafka) => Connection::Kafka(kafka.into_inline_connection(r)),
432            Connection::Csr(csr) => Connection::Csr(csr.into_inline_connection(r)),
433            Connection::GlueSchemaRegistry(glue) => {
434                Connection::GlueSchemaRegistry(glue.into_inline_connection(r))
435            }
436            Connection::Postgres(pg) => Connection::Postgres(pg.into_inline_connection(r)),
437            Connection::Ssh(ssh) => Connection::Ssh(ssh),
438            Connection::Aws(aws) => Connection::Aws(aws),
439            Connection::AwsPrivatelink(awspl) => Connection::AwsPrivatelink(awspl),
440            Connection::Gcp(gcp) => Connection::Gcp(gcp),
441            Connection::MySql(mysql) => Connection::MySql(mysql.into_inline_connection(r)),
442            Connection::SqlServer(sql_server) => {
443                Connection::SqlServer(sql_server.into_inline_connection(r))
444            }
445            Connection::IcebergCatalog(iceberg) => {
446                Connection::IcebergCatalog(iceberg.into_inline_connection(r))
447            }
448        }
449    }
450}
451
452impl<C: ConnectionAccess> Connection<C> {
453    /// Whether this connection should be validated by default on creation.
454    pub fn validate_by_default(&self) -> bool {
455        match self {
456            Connection::Kafka(conn) => conn.validate_by_default(),
457            Connection::Csr(conn) => conn.validate_by_default(),
458            Connection::GlueSchemaRegistry(conn) => conn.validate_by_default(),
459            Connection::Postgres(conn) => conn.validate_by_default(),
460            Connection::Ssh(conn) => conn.validate_by_default(),
461            Connection::Aws(conn) => conn.validate_by_default(),
462            Connection::AwsPrivatelink(conn) => conn.validate_by_default(),
463            Connection::Gcp(conn) => conn.validate_by_default(),
464            Connection::MySql(conn) => conn.validate_by_default(),
465            Connection::SqlServer(conn) => conn.validate_by_default(),
466            Connection::IcebergCatalog(conn) => conn.validate_by_default(),
467        }
468    }
469}
470
471impl Connection<InlinedConnection> {
472    /// Validates this connection by attempting to connect to the upstream system.
473    pub async fn validate(
474        &self,
475        id: CatalogItemId,
476        storage_configuration: &StorageConfiguration,
477    ) -> Result<(), ConnectionValidationError> {
478        match self {
479            Connection::Kafka(conn) => conn.validate(id, storage_configuration).await?,
480            Connection::Csr(conn) => conn.validate(id, storage_configuration).await?,
481            Connection::GlueSchemaRegistry(conn) => {
482                conn.validate(id, storage_configuration).await?
483            }
484            Connection::Postgres(conn) => {
485                conn.validate(id, storage_configuration).await?;
486            }
487            Connection::Ssh(conn) => conn.validate(id, storage_configuration).await?,
488            Connection::Aws(conn) => conn.validate(id, storage_configuration).await?,
489            Connection::AwsPrivatelink(conn) => conn.validate(id, storage_configuration).await?,
490            Connection::Gcp(conn) => conn.validate(id, storage_configuration).await?,
491            Connection::MySql(conn) => {
492                conn.validate(id, storage_configuration).await?;
493            }
494            Connection::SqlServer(conn) => {
495                conn.validate(id, storage_configuration).await?;
496            }
497            Connection::IcebergCatalog(conn) => conn.validate(id, storage_configuration).await?,
498        }
499        Ok(())
500    }
501
502    pub fn unwrap_kafka(self) -> <InlinedConnection as ConnectionAccess>::Kafka {
503        match self {
504            Self::Kafka(conn) => conn,
505            o => unreachable!("{o:?} is not a Kafka connection"),
506        }
507    }
508
509    pub fn unwrap_pg(self) -> <InlinedConnection as ConnectionAccess>::Pg {
510        match self {
511            Self::Postgres(conn) => conn,
512            o => unreachable!("{o:?} is not a Postgres connection"),
513        }
514    }
515
516    pub fn unwrap_mysql(self) -> <InlinedConnection as ConnectionAccess>::MySql {
517        match self {
518            Self::MySql(conn) => conn,
519            o => unreachable!("{o:?} is not a MySQL connection"),
520        }
521    }
522
523    pub fn unwrap_sql_server(self) -> <InlinedConnection as ConnectionAccess>::SqlServer {
524        match self {
525            Self::SqlServer(conn) => conn,
526            o => unreachable!("{o:?} is not a SQL Server connection"),
527        }
528    }
529
530    pub fn unwrap_aws(self) -> <InlinedConnection as ConnectionAccess>::Aws {
531        match self {
532            Self::Aws(conn) => conn,
533            o => unreachable!("{o:?} is not an AWS connection"),
534        }
535    }
536
537    pub fn unwrap_gcp(self) -> <InlinedConnection as ConnectionAccess>::Gcp {
538        match self {
539            Self::Gcp(conn) => conn,
540            o => unreachable!("{o:?} is not a GCP connection"),
541        }
542    }
543
544    pub fn unwrap_ssh(self) -> <InlinedConnection as ConnectionAccess>::Ssh {
545        match self {
546            Self::Ssh(conn) => conn,
547            o => unreachable!("{o:?} is not an SSH connection"),
548        }
549    }
550
551    pub fn unwrap_csr(self) -> <InlinedConnection as ConnectionAccess>::Csr {
552        match self {
553            Self::Csr(conn) => conn,
554            o => unreachable!("{o:?} is not a Kafka connection"),
555        }
556    }
557
558    pub fn unwrap_glue_schema_registry(
559        self,
560    ) -> <InlinedConnection as ConnectionAccess>::GlueSchemaRegistry {
561        match self {
562            Self::GlueSchemaRegistry(conn) => conn,
563            o => unreachable!("{o:?} is not an AWS Glue Schema Registry connection"),
564        }
565    }
566
567    pub fn unwrap_iceberg_catalog(self) -> <InlinedConnection as ConnectionAccess>::IcebergCatalog {
568        match self {
569            Self::IcebergCatalog(conn) => conn,
570            o => unreachable!("{o:?} is not an Iceberg catalog connection"),
571        }
572    }
573}
574
575/// An error returned by [`Connection::validate`].
576#[derive(thiserror::Error, Debug)]
577pub enum ConnectionValidationError {
578    #[error(transparent)]
579    Postgres(#[from] PostgresConnectionValidationError),
580    #[error(transparent)]
581    MySql(#[from] MySqlConnectionValidationError),
582    #[error(transparent)]
583    SqlServer(#[from] SqlServerConnectionValidationError),
584    #[error(transparent)]
585    Aws(#[from] AwsConnectionValidationError),
586    #[error(transparent)]
587    Gcp(#[from] gcp::GcpConnectionValidationError),
588    #[error(transparent)]
589    AwsPrivatelinkServiceName(#[from] InvalidAwsPrivatelinkServiceName),
590    #[error("{}", .0.display_with_causes())]
591    Other(#[from] anyhow::Error),
592}
593
594impl ConnectionValidationError {
595    /// Reports additional details about the error, if any are available.
596    pub fn detail(&self) -> Option<String> {
597        match self {
598            ConnectionValidationError::Postgres(e) => e.detail(),
599            ConnectionValidationError::MySql(e) => e.detail(),
600            ConnectionValidationError::SqlServer(e) => e.detail(),
601            ConnectionValidationError::Aws(e) => e.detail(),
602            ConnectionValidationError::Gcp(e) => e.detail(),
603            ConnectionValidationError::AwsPrivatelinkServiceName(_) => None,
604            ConnectionValidationError::Other(_) => None,
605        }
606    }
607
608    /// Reports a hint for the user about how the error could be fixed.
609    pub fn hint(&self) -> Option<String> {
610        match self {
611            ConnectionValidationError::Postgres(e) => e.hint(),
612            ConnectionValidationError::MySql(e) => e.hint(),
613            ConnectionValidationError::SqlServer(e) => e.hint(),
614            ConnectionValidationError::Aws(e) => e.hint(),
615            ConnectionValidationError::Gcp(e) => e.hint(),
616            ConnectionValidationError::AwsPrivatelinkServiceName(e) => Some(e.hint()),
617            ConnectionValidationError::Other(_) => None,
618        }
619    }
620}
621
622impl<C: ConnectionAccess> AlterCompatible for Connection<C> {
623    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
624        match (self, other) {
625            (Self::Aws(s), Self::Aws(o)) => s.alter_compatible(id, o),
626            (Self::AwsPrivatelink(s), Self::AwsPrivatelink(o)) => s.alter_compatible(id, o),
627            (Self::Gcp(s), Self::Gcp(o)) => s.alter_compatible(id, o),
628            (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o),
629            (Self::Csr(s), Self::Csr(o)) => s.alter_compatible(id, o),
630            (Self::Kafka(s), Self::Kafka(o)) => s.alter_compatible(id, o),
631            (Self::Postgres(s), Self::Postgres(o)) => s.alter_compatible(id, o),
632            (Self::MySql(s), Self::MySql(o)) => s.alter_compatible(id, o),
633            (Self::SqlServer(s), Self::SqlServer(o)) => s.alter_compatible(id, o),
634            (Self::GlueSchemaRegistry(s), Self::GlueSchemaRegistry(o)) => s.alter_compatible(id, o),
635            (Self::IcebergCatalog(s), Self::IcebergCatalog(o)) => s.alter_compatible(id, o),
636            _ => {
637                tracing::warn!(
638                    "Connection incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
639                    self,
640                    other
641                );
642                Err(AlterError { id })
643            }
644        }
645    }
646}
647
648/// Auth mechanism for Iceberg REST catalogs.
649#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
650pub enum IcebergCatalogAuth<C: ConnectionAccess = InlinedConnection> {
651    /// Use Iceberg catalog REST API's standard OAuth flow.
652    OAuth {
653        /// client_id:client_secret
654        credential: StringOrSecret,
655        /// OAuth2 scope
656        scope: Option<String>,
657        /// Where to exchange `credential` for a bearer token.
658        ///
659        /// `None` uses the endpoint the Iceberg REST specification defines relative to the
660        /// catalog URL, `<url>/v1/oauth/tokens`. Catalogs that host their token endpoint
661        /// elsewhere, or behind an auth gateway that will not serve an unauthenticated
662        /// exchange, need this override.
663        server_url: Option<String>,
664    },
665    Gcp(GcpConnectionReference<C>),
666}
667
668#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
669pub struct RestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
670    pub auth: IcebergCatalogAuth<C>,
671    /// The warehouse for REST catalogs
672    pub warehouse: Option<String>,
673    /// Which form of storage-access delegation to request from the catalog, if any.
674    ///
675    /// `None` means "do not request storage-access delegation".
676    /// If we do not have permission to request delegated access but request it anyway,
677    /// a catalog can reject our whole request,
678    /// even if we have our own storage credentials to fall back on.
679    pub access_delegation: Option<IcebergAccessDelegation>,
680    /// Which object store the catalog's tables live in.
681    ///
682    /// Defaults to S3. A REST catalog does not tell us this, so a table backed
683    /// by GCS or ADLS is unreadable until the connection says so.
684    pub storage_provider: IcebergStorageProvider,
685}
686
687/// The value Materialize sends in the Iceberg REST `X-Iceberg-Access-Delegation`
688/// header, naming how the catalog should grant access to table storage.
689#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
690pub enum IcebergAccessDelegation {
691    /// Ask the catalog to mint temporary, table-scoped storage credentials.
692    VendedCredentials,
693}
694
695impl IcebergAccessDelegation {
696    /// The header value, as spelled in the Iceberg REST specification.
697    pub fn as_header_value(&self) -> &'static str {
698        match self {
699            IcebergAccessDelegation::VendedCredentials => "vended-credentials",
700        }
701    }
702}
703
704impl<C: ConnectionAccess> AlterCompatible for RestIcebergCatalog<C> {
705    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
706        // Auth and access delegation are not considered for compatibility, because they can
707        // be changed without breaking existing tables.
708        if (self.warehouse != other.warehouse) || (self.storage_provider != other.storage_provider)
709        {
710            tracing::warn!(
711                "RestIcebergCatalog incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
712                self,
713                other
714            );
715            Err(AlterError { id })
716        } else {
717            Ok(())
718        }
719    }
720}
721
722#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
723pub struct S3TablesRestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
724    /// The AWS connection details, for s3tables
725    pub aws_connection: AwsConnectionReference<C>,
726    /// The warehouse for s3tables
727    pub warehouse: String,
728}
729
730impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogAuth, R>
731    for IcebergCatalogAuth<ReferencedConnection>
732{
733    fn into_inline_connection(self, r: R) -> IcebergCatalogAuth {
734        match self {
735            IcebergCatalogAuth::Gcp(x) => IcebergCatalogAuth::Gcp(x.into_inline_connection(&r)),
736            IcebergCatalogAuth::OAuth {
737                credential,
738                scope,
739                server_url,
740            } => IcebergCatalogAuth::OAuth {
741                credential,
742                scope,
743                server_url,
744            },
745        }
746    }
747}
748
749impl<R: ConnectionResolver> IntoInlineConnection<RestIcebergCatalog, R>
750    for RestIcebergCatalog<ReferencedConnection>
751{
752    fn into_inline_connection(self, r: R) -> RestIcebergCatalog {
753        RestIcebergCatalog {
754            auth: self.auth.into_inline_connection(&r),
755            warehouse: self.warehouse,
756            access_delegation: self.access_delegation,
757            storage_provider: self.storage_provider,
758        }
759    }
760}
761
762impl<R: ConnectionResolver> IntoInlineConnection<S3TablesRestIcebergCatalog, R>
763    for S3TablesRestIcebergCatalog<ReferencedConnection>
764{
765    fn into_inline_connection(self, r: R) -> S3TablesRestIcebergCatalog {
766        S3TablesRestIcebergCatalog {
767            aws_connection: self.aws_connection.into_inline_connection(&r),
768            warehouse: self.warehouse,
769        }
770    }
771}
772
773impl<C: ConnectionAccess> AlterCompatible for S3TablesRestIcebergCatalog<C> {
774    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
775        if self.aws_connection.connection_id != other.aws_connection.connection_id
776            || self.warehouse != other.warehouse
777        {
778            tracing::warn!(
779                "S3TablesRestIcebergCatalog incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
780                self,
781                other
782            );
783            Err(AlterError { id })
784        } else {
785            self.aws_connection
786                .connection
787                .alter_compatible(id, &other.aws_connection.connection)?;
788            Ok(())
789        }
790    }
791}
792
793#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
794pub enum IcebergCatalogType {
795    Rest,
796    S3TablesRest,
797}
798
799/// Which object store holds the data files of a REST catalog's tables.
800///
801/// The catalog protocol says nothing about this: a REST catalog hands back
802/// storage locations and credentials, and the client has to already know how to
803/// talk to that store. So it is configured per connection rather than
804/// discovered.
805#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
806pub enum IcebergStorageProvider {
807    S3,
808    Gcs,
809    Adls,
810}
811
812impl IcebergStorageProvider {
813    /// The name as spelled in SQL.
814    pub fn as_str(&self) -> &'static str {
815        match self {
816            IcebergStorageProvider::S3 => "s3",
817            IcebergStorageProvider::Gcs => "gcs",
818            IcebergStorageProvider::Adls => "adls",
819        }
820    }
821}
822
823impl Default for IcebergStorageProvider {
824    fn default() -> Self {
825        IcebergStorageProvider::S3
826    }
827}
828
829#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
830pub enum IcebergCatalogImpl<C: ConnectionAccess = InlinedConnection> {
831    Rest(RestIcebergCatalog<C>),
832    S3TablesRest(S3TablesRestIcebergCatalog<C>),
833}
834
835impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogImpl, R>
836    for IcebergCatalogImpl<ReferencedConnection>
837{
838    fn into_inline_connection(self, r: R) -> IcebergCatalogImpl {
839        match self {
840            IcebergCatalogImpl::Rest(rest) => {
841                IcebergCatalogImpl::Rest(rest.into_inline_connection(r))
842            }
843            IcebergCatalogImpl::S3TablesRest(s3tables) => {
844                IcebergCatalogImpl::S3TablesRest(s3tables.into_inline_connection(r))
845            }
846        }
847    }
848}
849
850impl<C: ConnectionAccess> AlterCompatible for IcebergCatalogImpl<C> {
851    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
852        match (self, other) {
853            (IcebergCatalogImpl::Rest(rest), IcebergCatalogImpl::Rest(other_rest)) => {
854                rest.alter_compatible(id, other_rest)
855            }
856            (
857                IcebergCatalogImpl::S3TablesRest(s3tables),
858                IcebergCatalogImpl::S3TablesRest(other_s3tables),
859            ) => s3tables.alter_compatible(id, other_s3tables),
860            _ => Err(AlterError { id }),
861        }
862    }
863}
864
865#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
866pub struct IcebergCatalogConnection<C: ConnectionAccess = InlinedConnection> {
867    /// The catalog impl impl of that catalog
868    pub catalog: IcebergCatalogImpl<C>,
869    /// Where the catalog is located
870    pub uri: reqwest_0_12::Url,
871}
872
873impl<C: ConnectionAccess> AlterCompatible for IcebergCatalogConnection<C> {
874    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
875        let compatibility_checks = [
876            (
877                self.catalog.alter_compatible(id, &other.catalog).is_ok(),
878                "catalog",
879            ),
880            (self.uri == other.uri, "uri"),
881        ];
882        for (is_ok, field) in compatibility_checks {
883            if !is_ok {
884                tracing::warn!(
885                    "IcebergCatalogConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
886                    self,
887                    other
888                );
889                return Err(AlterError { id });
890            }
891        }
892        Ok(())
893    }
894}
895
896impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogConnection, R>
897    for IcebergCatalogConnection<ReferencedConnection>
898{
899    fn into_inline_connection(self, r: R) -> IcebergCatalogConnection {
900        IcebergCatalogConnection {
901            catalog: self.catalog.into_inline_connection(&r),
902            uri: self.uri,
903        }
904    }
905}
906
907impl<C: ConnectionAccess> IcebergCatalogConnection<C> {
908    fn validate_by_default(&self) -> bool {
909        true
910    }
911}
912
913impl IcebergCatalogConnection<InlinedConnection> {
914    /// Connects to the catalog.
915    ///
916    /// `table` names the table this handle will be used against. It is needed only to keep
917    /// catalog-vended storage credentials refreshed, which the REST specification scopes to a
918    /// single table. Passing `None` leaves the connection on whatever credentials the catalog
919    /// supplies at `loadTable` time, which expire.
920    pub async fn connect(
921        &self,
922        storage_configuration: &StorageConfiguration,
923        in_task: InTask,
924        table: Option<&TableIdent>,
925    ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
926        match self.catalog {
927            IcebergCatalogImpl::S3TablesRest(ref s3tables) => {
928                // S3 Tables signs every request with SigV4 off a refreshable AWS provider, so it
929                // has no vended credential to keep alive.
930                self.connect_s3tables(s3tables, storage_configuration, in_task)
931                    .await
932            }
933            IcebergCatalogImpl::Rest(ref rest) => {
934                self.connect_rest(rest, storage_configuration, in_task, table)
935                    .await
936            }
937        }
938    }
939
940    pub fn catalog_type(&self) -> IcebergCatalogType {
941        match self.catalog {
942            IcebergCatalogImpl::S3TablesRest(_) => IcebergCatalogType::S3TablesRest,
943            IcebergCatalogImpl::Rest(_) => IcebergCatalogType::Rest,
944        }
945    }
946
947    pub fn s3tables_catalog(&self) -> Option<&S3TablesRestIcebergCatalog> {
948        match &self.catalog {
949            IcebergCatalogImpl::S3TablesRest(s3tables) => Some(s3tables),
950            IcebergCatalogImpl::Rest(_) => None,
951        }
952    }
953
954    pub fn rest_catalog(&self) -> Option<&RestIcebergCatalog> {
955        match &self.catalog {
956            IcebergCatalogImpl::Rest(rest) => Some(rest),
957            IcebergCatalogImpl::S3TablesRest(_) => None,
958        }
959    }
960
961    async fn connect_s3tables(
962        &self,
963        s3tables: &S3TablesRestIcebergCatalog,
964        storage_configuration: &StorageConfiguration,
965        in_task: InTask,
966    ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
967        let secret_reader = &storage_configuration.connection_context.secrets_reader;
968        let aws_ref = &s3tables.aws_connection;
969
970        let aws_region = aws_ref
971            .connection
972            .region
973            .clone()
974            .unwrap_or_else(|| "us-east-1".to_string());
975
976        let mut props = vec![
977            (S3_REGION.to_string(), aws_region.clone()),
978            (S3_DISABLE_EC2_METADATA.to_string(), "true".to_string()),
979            (
980                REST_CATALOG_PROP_WAREHOUSE.to_string(),
981                s3tables.warehouse.clone(),
982            ),
983            (REST_CATALOG_PROP_URI.to_string(), self.uri.to_string()),
984        ];
985
986        let aws_auth = aws_ref.connection.auth.clone();
987
988        if let AwsAuth::Credentials(creds) = &aws_auth {
989            props.push((
990                S3_ACCESS_KEY_ID.to_string(),
991                creds
992                    .access_key_id
993                    .get_string(in_task, secret_reader)
994                    .await?,
995            ));
996            props.push((
997                S3_SECRET_ACCESS_KEY.to_string(),
998                secret_reader.read_string(creds.secret_access_key).await?,
999            ));
1000        }
1001
1002        // Sign REST catalog requests with the Materialize AWS credential chain
1003        // via a custom `RequestAuthenticator`. For AssumeRole auth, also feed
1004        // the chain to OpenDAL's S3 loader so data-file IO uses the same creds.
1005        //
1006        // For AssumeRole auth, the provider serves cached credentials that a
1007        // background task keeps fresh, so no request through this catalog ever
1008        // waits on STS. The task lives as long as the catalog holds the
1009        // provider.
1010        let credentials_provider = match &aws_auth {
1011            // NOTE: This branch never contacts the connection's ENDPOINT.
1012            // REST requests go to the catalog URI and the STS calls use the
1013            // SDK defaults. The endpoint is still validated so a forbidden
1014            // one is rejected rather than silently ignored.
1015            AwsAuth::AssumeRole(assume_role) => {
1016                aws_ref.connection.validate_endpoint(
1017                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1018                )?;
1019                assume_role
1020                    .prefetch_credentials(
1021                        &storage_configuration.connection_context,
1022                        aws_ref.connection_id,
1023                        storage_configuration.config_set(),
1024                        format!("aws-connection-{}", aws_ref.connection_id),
1025                    )
1026                    .await
1027                    .with_context(|| {
1028                        format!(
1029                            "failed to initialize AssumeRole credentials for S3 Tables Iceberg \
1030                             catalog (catalog uri: {}, warehouse: {})",
1031                            self.uri, s3tables.warehouse
1032                        )
1033                    })?
1034            }
1035            AwsAuth::Credentials(_) => {
1036                let aws_config = aws_ref
1037                    .connection
1038                    .load_sdk_config(
1039                        &storage_configuration.connection_context,
1040                        aws_ref.connection_id,
1041                        in_task,
1042                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1043                    )
1044                    .await
1045                    .with_context(|| {
1046                        format!(
1047                            "failed to load AWS SDK config for S3 Tables Iceberg catalog \
1048                             (connection id: {}, auth method: {}, catalog uri: {}, warehouse: {})",
1049                            aws_ref.connection_id,
1050                            aws_ref.connection.auth_method(),
1051                            self.uri,
1052                            s3tables.warehouse
1053                        )
1054                    })?;
1055                aws_config
1056                    .credentials_provider()
1057                    .ok_or_else(|| anyhow!("aws_config missing credentials provider"))?
1058            }
1059        };
1060
1061        let authenticator = Arc::new(Sigv4Authenticator {
1062            provider: credentials_provider.clone(),
1063            region: aws_region.clone(),
1064            signing_name: "s3tables".to_string(),
1065        });
1066
1067        // N.B. We're using the AWS credentials from the catalog connection for the storage layer
1068        //   even though the sink comes with its own (unused) AWS credentials for storage.
1069        let customized_credential_load = if matches!(aws_auth, AwsAuth::AssumeRole(_)) {
1070            Some(CustomAwsCredentialLoader::new(AwsSdkCredentialLoader::new(
1071                credentials_provider,
1072            )))
1073        } else {
1074            None
1075        };
1076
1077        let storage_factory = Arc::new(OpenDalStorageFactory::S3 {
1078            customized_credential_load,
1079        });
1080
1081        let catalog = RestCatalogBuilder::default()
1082            .with_storage_factory(storage_factory)
1083            .with_authenticator(authenticator)
1084            .load("IcebergCatalog", props.into_iter().collect())
1085            .await
1086            .with_context(|| {
1087                format!(
1088                    "failed to create S3 Tables Iceberg catalog \
1089                     (connection id: {}, catalog uri: {}, warehouse: {})",
1090                    aws_ref.connection_id, self.uri, s3tables.warehouse
1091                )
1092            })?;
1093
1094        Ok(Arc::new(catalog))
1095    }
1096
1097    /// Collects the headers `iceberg-rust` puts on every catalog request out of the `header.*`
1098    /// props it takes them from.
1099    ///
1100    /// The credential endpoints Materialize calls directly bypass the catalog client, so without
1101    /// this they would reach the same server missing headers it may require, `x-goog-user-project`
1102    /// on a GCP-hosted catalog among them.
1103    fn catalog_headers(props: &BTreeMap<String, String>) -> Result<HeaderMap, anyhow::Error> {
1104        props
1105            .iter()
1106            .filter_map(|(k, v)| {
1107                k.strip_prefix(REST_CATALOG_HEADER_PROP_PREFIX)
1108                    .map(|name| (name, v))
1109            })
1110            .map(|(name, value)| {
1111                let name = HeaderName::try_from(name)
1112                    .with_context(|| format!("invalid Iceberg catalog header name: {name}"))?;
1113                let value = HeaderValue::try_from(value)
1114                    .with_context(|| format!("invalid Iceberg catalog header value for {name}"))?;
1115                Ok((name, value))
1116            })
1117            .collect()
1118    }
1119
1120    /// Resolves the endpoint that vends storage credentials for `table`, or `None` if this
1121    /// connection has no use for one.
1122    ///
1123    /// A loader built on the returned endpoint takes sole responsibility for storage credentials,
1124    /// so `None` is the signal to leave the catalog's static `storage-credentials` props in force.
1125    /// It means either that the connection did not ask for delegation, or that the caller named no
1126    /// table, and the specification scopes vended credentials to a single table.
1127    async fn vended_credential_endpoint(
1128        &self,
1129        rest: &RestIcebergCatalog,
1130        client: &reqwest_0_12::Client,
1131        token: &Arc<dyn TokenProvider>,
1132        headers: &HeaderMap,
1133        table: Option<&TableIdent>,
1134    ) -> Result<Option<Url>, anyhow::Error> {
1135        match (&rest.access_delegation, table) {
1136            (Some(IcebergAccessDelegation::VendedCredentials), Some(table)) => Ok(Some(
1137                iceberg_credentials::table_credentials_endpoint(
1138                    &self.uri,
1139                    client,
1140                    token,
1141                    headers,
1142                    rest.warehouse.as_deref(),
1143                    table,
1144                )
1145                .await?,
1146            )),
1147            _ => Ok(None),
1148        }
1149    }
1150
1151    /// Builds a GCS storage factory, refreshing vended credentials from `endpoint` if there is one.
1152    fn gcs_storage_factory(
1153        endpoint: Option<Url>,
1154        client: &reqwest_0_12::Client,
1155        token: &Arc<dyn TokenProvider>,
1156        headers: &HeaderMap,
1157    ) -> OpenDalStorageFactory {
1158        OpenDalStorageFactory::Gcs {
1159            customized_credential_load: endpoint.map(|endpoint| {
1160                CustomGcsCredentialLoader::new(iceberg_credentials::VendedCredentialLoader::new(
1161                    client.clone(),
1162                    endpoint,
1163                    Arc::clone(token),
1164                    headers.clone(),
1165                ))
1166            }),
1167        }
1168    }
1169
1170    async fn connect_rest(
1171        &self,
1172        rest: &RestIcebergCatalog,
1173        storage_configuration: &StorageConfiguration,
1174        in_task: InTask,
1175        table: Option<&TableIdent>,
1176    ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
1177        let mut props = BTreeMap::from([(
1178            REST_CATALOG_PROP_URI.to_string(),
1179            self.uri.to_string().clone(),
1180        )]);
1181
1182        if let Some(warehouse) = &rest.warehouse {
1183            props.insert(REST_CATALOG_PROP_WAREHOUSE.to_string(), warehouse.clone());
1184        }
1185
1186        // One client for catalog requests, OAuth token requests, and credential refreshes, so all
1187        // three share a connection pool. `iceberg-rust` would otherwise default to its own.
1188        let client = reqwest_0_12::Client::new();
1189
1190        // Catalog auth is configured through a combination of `props` and `.with_authenticator(...)`,
1191        // which happen at different stages of the [`RestCatalogBuilder`] -> [`RestCatalog`]
1192        // construction pipeline.
1193        let (storage_factory, custom_authenticator) = match &rest.auth {
1194            IcebergCatalogAuth::OAuth {
1195                credential,
1196                scope,
1197                server_url,
1198            } => {
1199                let credential = credential
1200                    .get_string(
1201                        in_task,
1202                        &storage_configuration.connection_context.secrets_reader,
1203                    )
1204                    .await
1205                    .map_err(|e| anyhow!("failed to read Iceberg catalog credential: {e}"))?;
1206
1207                if let Some(server_url) = server_url {
1208                    // The OAuth2 exchange POSTs the catalog credential to this URL, so a URL
1209                    // aimed inside our own network turns the connection into a request forger
1210                    // against, say, a cloud metadata endpoint. Resolve it and reject private
1211                    // addresses, the same check every other host we dial directly gets.
1212                    //
1213                    // NOTE: the resolved addresses are only checked, not pinned. The catalog
1214                    // client offers no hook to dial a pre-resolved address, so a name that
1215                    // resolves differently between this check and the request slips through.
1216                    // Kafka and Confluent Schema Registry connections do pin theirs.
1217                    let url = Url::parse(server_url).with_context(|| {
1218                        format!("invalid OAUTH2 SERVER URL for Iceberg catalog: {server_url}")
1219                    })?;
1220                    let host = url.host_str().ok_or_else(|| {
1221                        anyhow!("OAUTH2 SERVER URL for Iceberg catalog has no host: {server_url}")
1222                    })?;
1223                    resolve_address(
1224                        host,
1225                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1226                    )
1227                    .await
1228                    .with_context(|| {
1229                        format!("OAUTH2 SERVER URL for Iceberg catalog is not resolvable to an external address: {server_url}")
1230                    })?;
1231
1232                    props.insert(
1233                        REST_CATALOG_PROP_OAUTH2_SERVER_URI.to_string(),
1234                        server_url.clone(),
1235                    );
1236                }
1237
1238                // Materialize builds an OAuth2 provider shared across both catalog requests
1239                // and the vended credentials refresh below.
1240                let token_endpoint = match server_url {
1241                    Some(server_url) => server_url.clone(),
1242                    // Matches `iceberg-rust`'s default when no `oauth2-server-uri` is configured.
1243                    None => format!(
1244                        "{}/v1/oauth/tokens",
1245                        self.uri.as_str().trim_end_matches('/')
1246                    ),
1247                };
1248                let (client_id, client_secret) = match credential.split_once(':') {
1249                    Some((client_id, client_secret)) => {
1250                        (Some(client_id.to_string()), client_secret.to_string())
1251                    }
1252                    None => (None, credential),
1253                };
1254                let oauth_params = BTreeMap::from([(
1255                    OAUTH2_PARAM_SCOPE.to_string(),
1256                    // The default `iceberg-rust` applies when the connection names no scope.
1257                    scope.clone().unwrap_or_else(|| "catalog".to_string()),
1258                )]);
1259                let token: Arc<dyn TokenProvider> = Arc::new(OAuth2TokenProvider::new(
1260                    client.clone(),
1261                    client_id,
1262                    client_secret,
1263                    token_endpoint,
1264                    // The token request needs none of the catalog's headers, and
1265                    // `OAuth2TokenProvider` sets the form content type itself.
1266                    HeaderMap::new(),
1267                    oauth_params.into_iter().collect(),
1268                ));
1269
1270                let headers = Self::catalog_headers(&props)?;
1271                let endpoint = self
1272                    .vended_credential_endpoint(rest, &client, &token, &headers, table)
1273                    .await?;
1274
1275                (
1276                    // The catalog tells us where the data lives but not what
1277                    // kind of store it is, so the connection has to say.
1278                    match rest.storage_provider {
1279                        // When used with MinIO, Polaris returns a config with:
1280                        //   s3.access-key-id, s3.secret-access-key, s3.endpoint, ...
1281                        // `iceberg-rust` forwards these props to `opendal`. When the catalog
1282                        // vends instead, it returns per-table `storage-credentials` that
1283                        // `iceberg-rust` wires into the same FileIO.
1284                        // N.B. This is not confirmed to work with other catalog & storage implementations.
1285                        IcebergStorageProvider::S3 => OpenDalStorageFactory::S3 {
1286                            customized_credential_load: endpoint.map(|endpoint| {
1287                                CustomAwsCredentialLoader::new(
1288                                    iceberg_credentials::VendedCredentialLoader::new(
1289                                        client.clone(),
1290                                        endpoint,
1291                                        Arc::clone(&token),
1292                                        headers.clone(),
1293                                    ),
1294                                )
1295                            }),
1296                        },
1297                        IcebergStorageProvider::Gcs => {
1298                            Self::gcs_storage_factory(endpoint, &client, &token, &headers)
1299                        }
1300                        IcebergStorageProvider::Adls => OpenDalStorageFactory::Azdls {
1301                            customized_credential_load: endpoint.map(|endpoint| {
1302                                CustomAzdlsCredentialLoader::new(
1303                                    iceberg_credentials::VendedCredentialLoader::new(
1304                                        client.clone(),
1305                                        endpoint,
1306                                        Arc::clone(&token),
1307                                        headers.clone(),
1308                                    ),
1309                                )
1310                            }),
1311                        },
1312                    },
1313                    // NOTE: We construct our own OAuth authenticator for the Catalog client instead of using the one built in.
1314                    // This means we ignore auth overrides from `/v1/config` (e.g. `oauth2-server-uri`).
1315                    // This is okay because users can set these configs from Mz SQL.
1316                    Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
1317                )
1318            }
1319            IcebergCatalogAuth::Gcp(gcp_connection_reference) => {
1320                let (creds_json, service_account) = gcp_connection_reference
1321                    .connection
1322                    .read_credentials(storage_configuration)
1323                    .await
1324                    .map_err(|e| anyhow!("failed to parse GCP service account JSON: {e}"))?;
1325
1326                props.insert(
1327                    GCS_CREDENTIALS_JSON.to_owned(),
1328                    base64::engine::general_purpose::STANDARD.encode(creds_json),
1329                );
1330                // We supplied a service account key. Don't look elsewhere for GCP credentials.
1331                props.insert(GCS_DISABLE_VM_METADATA.to_owned(), "true".to_owned());
1332                props.insert(GCS_DISABLE_CONFIG_LOAD.to_owned(), "true".to_owned());
1333                if let Some(project_id) = service_account.project_id() {
1334                    props.insert(GCS_USER_PROJECT.to_owned(), project_id.to_owned());
1335                    props.insert(
1336                        "header.x-goog-user-project".to_owned(),
1337                        project_id.to_owned(),
1338                    );
1339                }
1340
1341                // The service account authenticates catalog requests whether or not the catalog
1342                // vends storage credentials, and doubles as the token source for refreshing them.
1343                let token: Arc<dyn TokenProvider> = Arc::new(GcpTokenProvider { service_account });
1344                let headers = Self::catalog_headers(&props)?;
1345                let endpoint = self
1346                    .vended_credential_endpoint(rest, &client, &token, &headers, table)
1347                    .await?;
1348
1349                (
1350                    // A GCP-hosted catalog vends GCS credentials, so the storage provider is not
1351                    // in question here the way it is for a generic REST catalog.
1352                    //
1353                    // NOTE: with delegation the service account key above stops governing storage
1354                    // and only authenticates the catalog. That is not a change in precedence:
1355                    // OpenDAL already preferred the vended token over a credential file, and a
1356                    // loader only keeps that token from expiring.
1357                    Self::gcs_storage_factory(endpoint, &client, &token, &headers),
1358                    Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
1359                )
1360            }
1361        };
1362
1363        // Inserted after the storage factory is built, so that the loaders above carry only the
1364        // headers the catalog client would send on an ordinary request. Each adds this one itself,
1365        // since the credentials endpoint is the one request that always asks for delegation.
1366        if let Some(delegation) = &rest.access_delegation {
1367            props.insert(
1368                REST_CATALOG_PROP_ACCESS_DELEGATION.to_string(),
1369                delegation.as_header_value().to_string(),
1370            );
1371        }
1372
1373        let mut catalog = RestCatalogBuilder::default()
1374            .with_storage_factory(Arc::new(storage_factory))
1375            .with_client(client);
1376        if let Some(auth) = custom_authenticator {
1377            catalog = catalog.with_authenticator(Arc::new(auth));
1378        }
1379        let catalog = catalog
1380            .load("IcebergCatalog", props.into_iter().collect())
1381            .await
1382            .map_err(|e| anyhow!("failed to create Iceberg catalog: {e}"))?;
1383        Ok(Arc::new(catalog))
1384    }
1385
1386    async fn validate(
1387        &self,
1388        _id: CatalogItemId,
1389        storage_configuration: &StorageConfiguration,
1390    ) -> Result<(), ConnectionValidationError> {
1391        // Validation only lists namespaces, so it needs no table-scoped credentials.
1392        let catalog = self
1393            .connect(storage_configuration, InTask::No, None)
1394            .await
1395            .map_err(|e| {
1396                ConnectionValidationError::Other(anyhow!("failed to connect to catalog: {e}"))
1397            })?;
1398
1399        // If we can list namespaces, the connection is valid.
1400        catalog.list_namespaces(None).await.map_err(|e| {
1401            ConnectionValidationError::Other(anyhow!("failed to list namespaces: {e}"))
1402        })?;
1403
1404        Ok(())
1405    }
1406}
1407
1408#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1409pub struct AwsPrivatelinkConnection {
1410    pub service_name: String,
1411    pub availability_zones: Vec<String>,
1412}
1413
1414impl AlterCompatible for AwsPrivatelinkConnection {
1415    fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
1416        // Every element of the AwsPrivatelinkConnection connection is configurable.
1417        Ok(())
1418    }
1419}
1420
1421/// A `SERVICE NAME` that cannot name an AWS VPC endpoint service.
1422#[derive(Clone, Debug, Eq, PartialEq)]
1423pub struct InvalidAwsPrivatelinkServiceName {
1424    pub name: String,
1425}
1426
1427impl fmt::Display for InvalidAwsPrivatelinkServiceName {
1428    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1429        write!(
1430            f,
1431            "invalid AWS PrivateLink service name {}",
1432            self.name.quoted()
1433        )
1434    }
1435}
1436
1437impl std::error::Error for InvalidAwsPrivatelinkServiceName {}
1438
1439impl InvalidAwsPrivatelinkServiceName {
1440    /// Explains how to find the right value.
1441    pub fn hint(&self) -> String {
1442        "SERVICE NAME must name an AWS VPC endpoint service, for example \
1443         `com.amazonaws.vpce.us-east-1.vpce-svc-0e123abc123198abc`. Endpoint service names are \
1444         listed in the AWS console under VPC > Endpoint services."
1445            .into()
1446    }
1447}
1448
1449impl AwsPrivatelinkConnection {
1450    /// Checks that `service_name` could name an AWS VPC endpoint service.
1451    ///
1452    /// Every endpoint service name starts with `com.amazonaws.`, whether the
1453    /// service is customer-owned (`com.amazonaws.vpce.<region>.vpce-svc-<id>`)
1454    /// or AWS-managed (`com.amazonaws.<region>.<service>`). Only the prefix is
1455    /// checked, so a name AWS would accept is never rejected here.
1456    pub fn check_service_name(service_name: &str) -> Result<(), InvalidAwsPrivatelinkServiceName> {
1457        if service_name.starts_with("com.amazonaws.") {
1458            return Ok(());
1459        }
1460        Err(InvalidAwsPrivatelinkServiceName {
1461            name: service_name.to_string(),
1462        })
1463    }
1464}
1465
1466#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1467pub struct KafkaTlsConfig {
1468    pub identity: Option<TlsIdentity>,
1469    pub root_cert: Option<StringOrSecret>,
1470}
1471
1472#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1473pub struct KafkaSaslConfig<C: ConnectionAccess = InlinedConnection> {
1474    pub mechanism: String,
1475    pub username: StringOrSecret,
1476    pub password: Option<CatalogItemId>,
1477    pub aws: Option<AwsConnectionReference<C>>,
1478}
1479
1480impl<R: ConnectionResolver> IntoInlineConnection<KafkaSaslConfig, R>
1481    for KafkaSaslConfig<ReferencedConnection>
1482{
1483    fn into_inline_connection(self, r: R) -> KafkaSaslConfig {
1484        KafkaSaslConfig {
1485            mechanism: self.mechanism,
1486            username: self.username,
1487            password: self.password,
1488            aws: self.aws.map(|aws| aws.into_inline_connection(&r)),
1489        }
1490    }
1491}
1492
1493/// Specifies a Kafka broker in a [`KafkaConnection`].
1494#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1495pub struct KafkaBroker<C: ConnectionAccess = InlinedConnection> {
1496    /// The address of the Kafka broker.
1497    pub address: String,
1498    /// An optional tunnel to use when connecting to the broker.
1499    pub tunnel: Tunnel<C>,
1500}
1501
1502impl<R: ConnectionResolver> IntoInlineConnection<KafkaBroker, R>
1503    for KafkaBroker<ReferencedConnection>
1504{
1505    fn into_inline_connection(self, r: R) -> KafkaBroker {
1506        let KafkaBroker { address, tunnel } = self;
1507        KafkaBroker {
1508            address,
1509            tunnel: tunnel.into_inline_connection(r),
1510        }
1511    }
1512}
1513
1514#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Default)]
1515pub struct KafkaTopicOptions {
1516    /// The replication factor for the topic.
1517    /// If `None`, the broker default will be used.
1518    pub replication_factor: Option<NonNeg<i32>>,
1519    /// The number of partitions to create.
1520    /// If `None`, the broker default will be used.
1521    pub partition_count: Option<NonNeg<i32>>,
1522    /// The initial configuration parameters for the topic.
1523    pub topic_config: BTreeMap<String, String>,
1524}
1525
1526#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1527pub struct KafkaConnection<C: ConnectionAccess = InlinedConnection> {
1528    pub brokers: Vec<KafkaBroker<C>>,
1529    /// A tunnel through which to route traffic,
1530    /// that can be overridden for individual brokers
1531    /// in `brokers`.
1532    pub default_tunnel: Tunnel<C>,
1533    pub progress_topic: Option<String>,
1534    pub progress_topic_options: KafkaTopicOptions,
1535    pub options: BTreeMap<String, StringOrSecret>,
1536    pub tls: Option<KafkaTlsConfig>,
1537    pub sasl: Option<KafkaSaslConfig<C>>,
1538}
1539
1540impl<R: ConnectionResolver> IntoInlineConnection<KafkaConnection, R>
1541    for KafkaConnection<ReferencedConnection>
1542{
1543    fn into_inline_connection(self, r: R) -> KafkaConnection {
1544        let KafkaConnection {
1545            brokers,
1546            progress_topic,
1547            progress_topic_options,
1548            default_tunnel,
1549            options,
1550            tls,
1551            sasl,
1552        } = self;
1553
1554        let brokers = brokers
1555            .into_iter()
1556            .map(|broker| broker.into_inline_connection(&r))
1557            .collect();
1558
1559        KafkaConnection {
1560            brokers,
1561            progress_topic,
1562            progress_topic_options,
1563            default_tunnel: default_tunnel.into_inline_connection(&r),
1564            options,
1565            tls,
1566            sasl: sasl.map(|sasl| sasl.into_inline_connection(&r)),
1567        }
1568    }
1569}
1570
1571impl<C: ConnectionAccess> KafkaConnection<C> {
1572    /// Returns the name of the progress topic to use for the connection.
1573    ///
1574    /// The caller is responsible for providing the connection ID as it is not
1575    /// known to `KafkaConnection`.
1576    ///
1577    /// NOTE: the `mz_catalog.mz_kafka_connections` builtin materialized view
1578    /// reconstructs the default (`_materialize-progress-<env>-<conn>`) in SQL
1579    /// (see `MZ_KAFKA_CONNECTIONS` in `src/catalog/src/builtin/mz_catalog.rs`).
1580    /// Keep the two in sync.
1581    pub fn progress_topic(
1582        &self,
1583        connection_context: &ConnectionContext,
1584        connection_id: CatalogItemId,
1585    ) -> Cow<'_, str> {
1586        if let Some(progress_topic) = &self.progress_topic {
1587            Cow::Borrowed(progress_topic)
1588        } else {
1589            Cow::Owned(format!(
1590                "_materialize-progress-{}-{}",
1591                connection_context.environment_id, connection_id,
1592            ))
1593        }
1594    }
1595
1596    fn validate_by_default(&self) -> bool {
1597        true
1598    }
1599}
1600
1601impl KafkaConnection {
1602    /// Generates a string that can be used as the base for a configuration ID
1603    /// (e.g., `client.id`, `group.id`, `transactional.id`) for a Kafka source
1604    /// or sink.
1605    ///
1606    /// NOTE: the `mz_catalog.mz_kafka_sources` builtin materialized view
1607    /// reconstructs this exact `materialize-<env>-<conn>-<obj>` format in SQL
1608    /// (see `MZ_KAFKA_SOURCES` in `src/catalog/src/builtin/mz_catalog.rs`).
1609    /// The two must stay in sync. `test/testdrive/kafka-commit.td` guards this
1610    /// by feeding the view's reconstructed value into `kafka-verify-commit`,
1611    /// so a divergence here fails that test.
1612    pub fn id_base(
1613        connection_context: &ConnectionContext,
1614        connection_id: CatalogItemId,
1615        object_id: GlobalId,
1616    ) -> String {
1617        format!(
1618            "materialize-{}-{}-{}",
1619            connection_context.environment_id, connection_id, object_id,
1620        )
1621    }
1622
1623    /// Enriches the provided `client_id` according to any enrichment rules in
1624    /// the `kafka_client_id_enrichment_rules` configuration parameter.
1625    pub fn enrich_client_id(&self, configs: &ConfigSet, client_id: &mut String) {
1626        #[derive(Debug, Deserialize)]
1627        struct EnrichmentRule {
1628            #[serde(deserialize_with = "deserialize_regex")]
1629            pattern: Regex,
1630            payload: String,
1631        }
1632
1633        fn deserialize_regex<'de, D>(deserializer: D) -> Result<Regex, D::Error>
1634        where
1635            D: Deserializer<'de>,
1636        {
1637            let buf = String::deserialize(deserializer)?;
1638            Regex::new(&buf).map_err(serde::de::Error::custom)
1639        }
1640
1641        let rules = KAFKA_CLIENT_ID_ENRICHMENT_RULES.get(configs);
1642        let rules = match serde_json::from_value::<Vec<EnrichmentRule>>(rules) {
1643            Ok(rules) => rules,
1644            Err(e) => {
1645                warn!(%e, "failed to decode kafka_client_id_enrichment_rules");
1646                return;
1647            }
1648        };
1649
1650        // Check every rule against every broker. Rules are matched in the order
1651        // that they are specified. It is usually a configuration error if
1652        // multiple rules match the same list of Kafka brokers, but we
1653        // nonetheless want to provide well defined semantics.
1654        debug!(?self.brokers, "evaluating client ID enrichment rules");
1655        for rule in rules {
1656            let is_match = self
1657                .brokers
1658                .iter()
1659                .any(|b| rule.pattern.is_match(&b.address));
1660            debug!(?rule, is_match, "evaluated client ID enrichment rule");
1661            if is_match {
1662                client_id.push('-');
1663                client_id.push_str(&rule.payload);
1664            }
1665        }
1666    }
1667
1668    /// Creates a Kafka client for the connection.
1669    pub async fn create_with_context<C, T>(
1670        &self,
1671        storage_configuration: &StorageConfiguration,
1672        context: C,
1673        extra_options: &BTreeMap<&str, String>,
1674        in_task: InTask,
1675    ) -> Result<T, ContextCreationError>
1676    where
1677        C: ClientContext,
1678        T: FromClientConfigAndContext<TunnelingClientContext<C>>,
1679    {
1680        let mut options = self.options.clone();
1681
1682        // Ensure that Kafka topics are *not* automatically created when
1683        // consuming, producing, or fetching metadata for a topic. This ensures
1684        // that we don't accidentally create topics with the wrong number of
1685        // partitions.
1686        options.insert("allow.auto.create.topics".into(), "false".into());
1687
1688        let brokers = match &self.default_tunnel {
1689            Tunnel::AwsPrivatelink(t) => {
1690                assert!(&self.brokers.is_empty());
1691
1692                let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1693                    .get(storage_configuration.config_set());
1694                options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1695
1696                // When using a default privatelink tunnel broker/brokers cannot be specified
1697                // instead the tunnel connection_id and port are used for the initial connection.
1698                format!(
1699                    "{}:{}",
1700                    vpc_endpoint_host(
1701                        t.connection_id,
1702                        None, // Default tunnel does not support availability zones.
1703                    ),
1704                    t.port.unwrap_or(9092)
1705                )
1706            }
1707            Tunnel::AwsPrivatelinks(_pl) => {
1708                let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1709                    .get(storage_configuration.config_set());
1710                options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1711
1712                if self.brokers.is_empty() {
1713                    return Err(ContextCreationError::Other(anyhow::anyhow!(
1714                        "at least one static broker is required when using BROKER or BROKERS"
1715                    )));
1716                }
1717                self.brokers.iter().map(|b| &b.address).join(",")
1718            }
1719            _ => self.brokers.iter().map(|b| &b.address).join(","),
1720        };
1721        options.insert("bootstrap.servers".into(), brokers.clone().into());
1722        let security_protocol = match (self.tls.is_some(), self.sasl.is_some()) {
1723            (false, false) => "PLAINTEXT",
1724            (true, false) => "SSL",
1725            (false, true) => "SASL_PLAINTEXT",
1726            (true, true) => "SASL_SSL",
1727        };
1728        info!(
1729            "kafka: create_with_context bootstrap.servers={brokers}, security_protocol={security_protocol}"
1730        );
1731        options.insert("security.protocol".into(), security_protocol.into());
1732        if let Some(tls) = &self.tls {
1733            if let Some(root_cert) = &tls.root_cert {
1734                options.insert("ssl.ca.pem".into(), root_cert.clone());
1735            }
1736            if let Some(identity) = &tls.identity {
1737                options.insert("ssl.key.pem".into(), StringOrSecret::Secret(identity.key));
1738                options.insert("ssl.certificate.pem".into(), identity.cert.clone());
1739            }
1740        }
1741        if let Some(sasl) = &self.sasl {
1742            options.insert("sasl.mechanisms".into(), (&sasl.mechanism).into());
1743            options.insert("sasl.username".into(), sasl.username.clone());
1744            if let Some(password) = sasl.password {
1745                options.insert("sasl.password".into(), StringOrSecret::Secret(password));
1746            }
1747        }
1748
1749        options.insert(
1750            "retry.backoff.ms".into(),
1751            KAFKA_RETRY_BACKOFF
1752                .get(storage_configuration.config_set())
1753                .as_millis()
1754                .into(),
1755        );
1756        options.insert(
1757            "retry.backoff.max.ms".into(),
1758            KAFKA_RETRY_BACKOFF_MAX
1759                .get(storage_configuration.config_set())
1760                .as_millis()
1761                .into(),
1762        );
1763        options.insert(
1764            "reconnect.backoff.ms".into(),
1765            KAFKA_RECONNECT_BACKOFF
1766                .get(storage_configuration.config_set())
1767                .as_millis()
1768                .into(),
1769        );
1770        options.insert(
1771            "reconnect.backoff.max.ms".into(),
1772            KAFKA_RECONNECT_BACKOFF_MAX
1773                .get(storage_configuration.config_set())
1774                .as_millis()
1775                .into(),
1776        );
1777
1778        let mut config = mz_kafka_util::client::create_new_client_config(
1779            storage_configuration
1780                .connection_context
1781                .librdkafka_log_level,
1782            storage_configuration.parameters.kafka_timeout_config,
1783        );
1784        for (k, v) in options {
1785            config.set(
1786                k,
1787                v.get_string(
1788                    in_task,
1789                    &storage_configuration.connection_context.secrets_reader,
1790                )
1791                .await
1792                .context("reading kafka secret")?,
1793            );
1794        }
1795        for (k, v) in extra_options {
1796            config.set(*k, v);
1797        }
1798
1799        let aws_config = match self.sasl.as_ref().and_then(|sasl| sasl.aws.as_ref()) {
1800            None => None,
1801            Some(aws) => Some(
1802                aws.connection
1803                    .load_sdk_config(
1804                        &storage_configuration.connection_context,
1805                        aws.connection_id,
1806                        in_task,
1807                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1808                    )
1809                    .await?,
1810            ),
1811        };
1812
1813        let mut context = TunnelingClientContext::new(
1814            context,
1815            Handle::current(),
1816            storage_configuration
1817                .connection_context
1818                .ssh_tunnel_manager
1819                .clone(),
1820            storage_configuration.parameters.ssh_timeout_config,
1821            aws_config,
1822            in_task,
1823            ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1824        );
1825
1826        match &self.default_tunnel {
1827            Tunnel::Direct => {
1828                // By default, don't offer a default override for broker address lookup.
1829            }
1830            Tunnel::AwsPrivatelink(pl) => {
1831                context.set_default_tunnel(TunnelConfig::StaticHost(
1832                    // Possible bug: We have been ignoring the configured port.
1833                    KafkaConnection::from_default_aws_privatelink(pl).host,
1834                ));
1835            }
1836            Tunnel::AwsPrivatelinks(pl) => {
1837                context.set_default_tunnel(TunnelConfig::Rules(
1838                    KafkaConnection::from_aws_privatelinks(pl),
1839                ));
1840            }
1841            Tunnel::Ssh(ssh_tunnel) => {
1842                let secret = storage_configuration
1843                    .connection_context
1844                    .secrets_reader
1845                    .read_in_task_if(in_task, ssh_tunnel.connection_id)
1846                    .await?;
1847                let key_pair = SshKeyPair::from_bytes(&secret)?;
1848
1849                // Ensure any ssh-bastion address we connect to is resolved to an external address.
1850                let resolved = resolve_address(
1851                    &ssh_tunnel.connection.host,
1852                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1853                )
1854                .await?;
1855                context.set_default_tunnel(TunnelConfig::Ssh(SshTunnelConfig {
1856                    host: resolved
1857                        .iter()
1858                        .map(|a| a.to_string())
1859                        .collect::<BTreeSet<_>>(),
1860                    port: ssh_tunnel.connection.port,
1861                    user: ssh_tunnel.connection.user.clone(),
1862                    key_pair,
1863                }));
1864            }
1865        }
1866        info!(
1867            "kafka: tunnel config set to {}",
1868            match &self.default_tunnel {
1869                Tunnel::Direct => "Direct".to_string(),
1870                Tunnel::AwsPrivatelink(_) => "AwsPrivatelink (static host)".to_string(),
1871                Tunnel::AwsPrivatelinks(pl) =>
1872                    format!("AwsPrivatelinks ({} rules)", pl.rules.len()),
1873                Tunnel::Ssh(_) => "Ssh".to_string(),
1874            }
1875        );
1876
1877        // Here, we preemptively rewrite broker addresses.
1878        // In concept, this overlaps with 'TunnelingClientContext::resolve_broker_addr'.
1879        for broker in &self.brokers {
1880            let mut addr_parts = broker.address.splitn(2, ':');
1881            let addr = BrokerAddr {
1882                host: addr_parts
1883                    .next()
1884                    .context("BROKER is not address:port")?
1885                    .into(),
1886                port: addr_parts
1887                    .next()
1888                    .unwrap_or("9092")
1889                    .parse()
1890                    .context("parsing BROKER port")?,
1891            };
1892            match &broker.tunnel {
1893                Tunnel::Direct => {
1894                    // By default, don't override broker address lookup.
1895                    //
1896                    // N.B.
1897                    //
1898                    // We _could_ pre-setup the default ssh tunnel for all known brokers here, but
1899                    // we avoid doing because:
1900                    // - Its not necessary.
1901                    // - Not doing so makes it easier to test the `FailedDefaultSshTunnel` path
1902                    // in the `TunnelingClientContext`.
1903                }
1904                Tunnel::AwsPrivatelink(aws_privatelink) => {
1905                    context.add_broker_rewrite(
1906                        addr,
1907                        KafkaConnection::from_aws_privatelink(aws_privatelink),
1908                    );
1909                }
1910                Tunnel::AwsPrivatelinks(_) => unreachable!(
1911                    "Individually predefined brokers do not use rule-based PrivateLinks routing."
1912                ),
1913                Tunnel::Ssh(ssh_tunnel) => {
1914                    // Ensure any SSH bastion address we connect to is resolved to an external address.
1915                    let ssh_host_resolved = resolve_address(
1916                        &ssh_tunnel.connection.host,
1917                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1918                    )
1919                    .await?;
1920                    context
1921                        .add_ssh_tunnel(
1922                            addr,
1923                            SshTunnelConfig {
1924                                host: ssh_host_resolved
1925                                    .iter()
1926                                    .map(|a| a.to_string())
1927                                    .collect::<BTreeSet<_>>(),
1928                                port: ssh_tunnel.connection.port,
1929                                user: ssh_tunnel.connection.user.clone(),
1930                                key_pair: SshKeyPair::from_bytes(
1931                                    &storage_configuration
1932                                        .connection_context
1933                                        .secrets_reader
1934                                        .read_in_task_if(in_task, ssh_tunnel.connection_id)
1935                                        .await?,
1936                                )?,
1937                            },
1938                        )
1939                        .await
1940                        .map_err(ContextCreationError::Ssh)?;
1941                }
1942            }
1943        }
1944
1945        Ok(mz_kafka_util::client::create_with_context(
1946            &config, context,
1947        )?)
1948    }
1949
1950    async fn validate(
1951        &self,
1952        _id: CatalogItemId,
1953        storage_configuration: &StorageConfiguration,
1954    ) -> Result<(), anyhow::Error> {
1955        let (context, error_rx) = MzClientContext::with_errors();
1956        let consumer: BaseConsumer<_> = self
1957            .create_with_context(
1958                storage_configuration,
1959                context,
1960                &BTreeMap::new(),
1961                // We are in a normal tokio context during validation, already.
1962                InTask::No,
1963            )
1964            .await?;
1965        let consumer = Arc::new(consumer);
1966
1967        let timeout = storage_configuration
1968            .parameters
1969            .kafka_timeout_config
1970            .fetch_metadata_timeout;
1971
1972        // librdkafka doesn't expose an API for determining whether a connection to
1973        // the Kafka cluster has been successfully established. So we make a
1974        // metadata request, though we don't care about the results, so that we can
1975        // report any errors making that request. If the request succeeds, we know
1976        // we were able to contact at least one broker, and that's a good proxy for
1977        // being able to contact all the brokers in the cluster.
1978        //
1979        // The downside of this approach is it produces a generic error message like
1980        // "metadata fetch error" with no additional details. The real networking
1981        // error is buried in the librdkafka logs, which are not visible to users.
1982        info!("kafka: starting connection validation via fetch_metadata (timeout={timeout:?})");
1983        let result = mz_ore::task::spawn_blocking(|| "kafka_get_metadata", {
1984            let consumer = Arc::clone(&consumer);
1985            move || consumer.fetch_metadata(None, timeout)
1986        })
1987        .await;
1988        info!(
1989            "kafka: connection validation result: {}",
1990            if result.is_ok() { "success" } else { "failed" },
1991        );
1992        match result {
1993            Ok(_) => Ok(()),
1994            // The error returned by `fetch_metadata` does not provide any details which makes for
1995            // a crappy user facing error message. For this reason we attempt to grab a better
1996            // error message from the client context, which should contain any error logs emitted
1997            // by librdkafka, and fallback to the generic error if there is nothing there.
1998            Err(err) => {
1999                // Multiple errors might have been logged during this validation but some are more
2000                // relevant than others. Specifically, we prefer non-internal errors over internal
2001                // errors since those give much more useful information to the users.
2002                let main_err = error_rx.try_iter().reduce(|cur, new| match cur {
2003                    MzKafkaError::Internal(_) => new,
2004                    _ => cur,
2005                });
2006
2007                // Don't drop the consumer until after we've drained the errors
2008                // channel. Dropping the consumer can introduce spurious errors.
2009                // See database-issues#7432.
2010                drop(consumer);
2011
2012                match main_err {
2013                    Some(err) => Err(err.into()),
2014                    None => Err(err.into()),
2015                }
2016            }
2017        }
2018    }
2019
2020    /// The "default" PrivateLink connection is used for bootstrapping Kafka.
2021    fn from_default_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
2022        BrokerRewrite {
2023            host: vpc_endpoint_host(
2024                pl.connection_id,
2025                None, // Default tunnel does not support availability zones.
2026            ),
2027            port: pl.port,
2028        }
2029    }
2030
2031    /// The "not default" PrivateLink connections are used for routing to specific Kafka brokers.
2032    fn from_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
2033        BrokerRewrite {
2034            host: vpc_endpoint_host(pl.connection_id, pl.availability_zone.as_deref()),
2035            port: pl.port,
2036        }
2037    }
2038
2039    fn from_aws_privatelink_rule(
2040        AwsPrivatelinkRule { pattern, to }: &AwsPrivatelinkRule,
2041    ) -> (mz_kafka_util::client::ConnectionRulePattern, BrokerRewrite) {
2042        (
2043            mz_kafka_util::client::ConnectionRulePattern {
2044                prefix_wildcard: pattern.prefix_wildcard,
2045                literal_match: pattern.literal_match.clone(),
2046                suffix_wildcard: pattern.suffix_wildcard,
2047            },
2048            KafkaConnection::from_aws_privatelink(to),
2049        )
2050    }
2051
2052    fn from_aws_privatelinks(pl: &AwsPrivatelinks) -> HostMappingRules {
2053        HostMappingRules {
2054            rules: pl
2055                .rules
2056                .iter()
2057                .map(KafkaConnection::from_aws_privatelink_rule)
2058                .collect_vec(),
2059        }
2060    }
2061}
2062
2063impl<C: ConnectionAccess> AlterCompatible for KafkaConnection<C> {
2064    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2065        let KafkaConnection {
2066            brokers: _,
2067            default_tunnel: _,
2068            progress_topic,
2069            progress_topic_options,
2070            options: _,
2071            tls: _,
2072            sasl: _,
2073        } = self;
2074
2075        let compatibility_checks = [
2076            (progress_topic == &other.progress_topic, "progress_topic"),
2077            (
2078                progress_topic_options == &other.progress_topic_options,
2079                "progress_topic_options",
2080            ),
2081        ];
2082
2083        for (compatible, field) in compatibility_checks {
2084            if !compatible {
2085                tracing::warn!(
2086                    "KafkaConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2087                    self,
2088                    other
2089                );
2090
2091                return Err(AlterError { id });
2092            }
2093        }
2094
2095        Ok(())
2096    }
2097}
2098
2099/// A connection to a Confluent Schema Registry.
2100#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2101pub struct CsrConnection<C: ConnectionAccess = InlinedConnection> {
2102    /// The URL of the schema registry.
2103    pub url: Url,
2104    /// Trusted root TLS certificate in PEM format.
2105    pub tls_root_cert: Option<StringOrSecret>,
2106    /// An optional TLS client certificate for authentication with the schema
2107    /// registry.
2108    pub tls_identity: Option<TlsIdentity>,
2109    /// Optional HTTP authentication credentials for the schema registry.
2110    pub http_auth: Option<CsrConnectionHttpAuth>,
2111    /// A tunnel through which to route traffic.
2112    pub tunnel: Tunnel<C>,
2113}
2114
2115impl<R: ConnectionResolver> IntoInlineConnection<CsrConnection, R>
2116    for CsrConnection<ReferencedConnection>
2117{
2118    fn into_inline_connection(self, r: R) -> CsrConnection {
2119        let CsrConnection {
2120            url,
2121            tls_root_cert,
2122            tls_identity,
2123            http_auth,
2124            tunnel,
2125        } = self;
2126        CsrConnection {
2127            url,
2128            tls_root_cert,
2129            tls_identity,
2130            http_auth,
2131            tunnel: tunnel.into_inline_connection(r),
2132        }
2133    }
2134}
2135
2136impl<C: ConnectionAccess> CsrConnection<C> {
2137    fn validate_by_default(&self) -> bool {
2138        true
2139    }
2140}
2141
2142impl CsrConnection {
2143    /// Constructs a schema registry client from the connection.
2144    pub async fn connect(
2145        &self,
2146        storage_configuration: &StorageConfiguration,
2147        in_task: InTask,
2148    ) -> Result<mz_ccsr::Client, CsrConnectError> {
2149        let mut client_config = mz_ccsr::ClientConfig::new(self.url.clone());
2150        if let Some(root_cert) = &self.tls_root_cert {
2151            let root_cert = root_cert
2152                .get_string(
2153                    in_task,
2154                    &storage_configuration.connection_context.secrets_reader,
2155                )
2156                .await?;
2157            let root_cert = Certificate::from_pem(root_cert.as_bytes())?;
2158            client_config = client_config.add_root_certificate(root_cert);
2159        }
2160
2161        if let Some(tls_identity) = &self.tls_identity {
2162            let key = &storage_configuration
2163                .connection_context
2164                .secrets_reader
2165                .read_string_in_task_if(in_task, tls_identity.key)
2166                .await?;
2167            let cert = tls_identity
2168                .cert
2169                .get_string(
2170                    in_task,
2171                    &storage_configuration.connection_context.secrets_reader,
2172                )
2173                .await?;
2174            let ident = Identity::from_pem(key.as_bytes(), cert.as_bytes())?;
2175            client_config = client_config.identity(ident);
2176        }
2177
2178        if let Some(http_auth) = &self.http_auth {
2179            let username = http_auth
2180                .username
2181                .get_string(
2182                    in_task,
2183                    &storage_configuration.connection_context.secrets_reader,
2184                )
2185                .await?;
2186            let password = match http_auth.password {
2187                None => None,
2188                Some(password) => Some(
2189                    storage_configuration
2190                        .connection_context
2191                        .secrets_reader
2192                        .read_string_in_task_if(in_task, password)
2193                        .await?,
2194                ),
2195            };
2196            client_config = client_config.auth(username, password);
2197        }
2198
2199        // TODO: use types to enforce that the URL has a string hostname.
2200        let host = self
2201            .url
2202            .host_str()
2203            .ok_or_else(|| anyhow!("url missing host"))?;
2204        match &self.tunnel {
2205            Tunnel::Direct => {
2206                // Ensure any host we connect to is resolved to an external address.
2207                let resolved = resolve_address(
2208                    host,
2209                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2210                )
2211                .await?;
2212                client_config = client_config.resolve_to_addrs(
2213                    host,
2214                    &resolved
2215                        .iter()
2216                        .map(|addr| SocketAddr::new(*addr, 0))
2217                        .collect::<Vec<_>>(),
2218                )
2219            }
2220            Tunnel::Ssh(ssh_tunnel) => {
2221                let ssh_tunnel = ssh_tunnel
2222                    .connect(
2223                        storage_configuration,
2224                        host,
2225                        // Honor the URL scheme's default port (443 for https,
2226                        // 80 for http) if no explicit port was provided.
2227                        self.url.port_or_known_default().unwrap_or(80),
2228                        in_task,
2229                    )
2230                    .await
2231                    .map_err(CsrConnectError::Ssh)?;
2232
2233                // Carefully inject the SSH tunnel into the client
2234                // configuration. This is delicate because we need TLS
2235                // verification to continue to use the remote hostname rather
2236                // than the tunnel hostname.
2237
2238                client_config = client_config
2239                    // `resolve_to_addrs` allows us to rewrite the hostname
2240                    // at the DNS level, which means the TCP connection is
2241                    // correctly routed through the tunnel, but TLS verification
2242                    // is still performed against the remote hostname.
2243                    // Unfortunately the port here is ignored if the URL also
2244                    // specifies a port...
2245                    .resolve_to_addrs(host, &[SocketAddr::new(ssh_tunnel.local_addr().ip(), 0)])
2246                    // ...so we also dynamically rewrite the URL to use the
2247                    // current port for the SSH tunnel.
2248                    //
2249                    // WARNING: this is brittle, because we only dynamically
2250                    // update the client configuration with the tunnel *port*,
2251                    // and not the hostname This works fine in practice, because
2252                    // only the SSH tunnel port will change if the tunnel fails
2253                    // and has to be restarted (the hostname is always
2254                    // 127.0.0.1)--but this is an an implementation detail of
2255                    // the SSH tunnel code that we're relying on.
2256                    .dynamic_url({
2257                        let remote_url = self.url.clone();
2258                        move || {
2259                            let mut url = remote_url.clone();
2260                            url.set_port(Some(ssh_tunnel.local_addr().port()))
2261                                .expect("cannot fail");
2262                            url
2263                        }
2264                    });
2265            }
2266            Tunnel::AwsPrivatelink(connection) => {
2267                assert_none!(connection.port);
2268
2269                let privatelink_host = mz_cloud_resources::vpc_endpoint_host(
2270                    connection.connection_id,
2271                    connection.availability_zone.as_deref(),
2272                );
2273                let addrs: Vec<_> = net::lookup_host((privatelink_host, 0))
2274                    .await
2275                    .context("resolving PrivateLink host")?
2276                    .collect();
2277                client_config = client_config.resolve_to_addrs(host, &addrs)
2278            }
2279            Tunnel::AwsPrivatelinks(_) => {
2280                unreachable!("MATCHING broker rules are only available for Kafka connections.");
2281            }
2282        }
2283
2284        Ok(client_config.build()?)
2285    }
2286
2287    async fn validate(
2288        &self,
2289        _id: CatalogItemId,
2290        storage_configuration: &StorageConfiguration,
2291    ) -> Result<(), anyhow::Error> {
2292        let client = self
2293            .connect(
2294                storage_configuration,
2295                // We are in a normal tokio context during validation, already.
2296                InTask::No,
2297            )
2298            .await?;
2299        client.list_subjects().await?;
2300        Ok(())
2301    }
2302}
2303
2304impl<C: ConnectionAccess> AlterCompatible for CsrConnection<C> {
2305    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2306        let CsrConnection {
2307            tunnel,
2308            // All non-tunnel fields may change
2309            url: _,
2310            tls_root_cert: _,
2311            tls_identity: _,
2312            http_auth: _,
2313        } = self;
2314
2315        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2316
2317        for (compatible, field) in compatibility_checks {
2318            if !compatible {
2319                tracing::warn!(
2320                    "CsrConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2321                    self,
2322                    other
2323                );
2324
2325                return Err(AlterError { id });
2326            }
2327        }
2328        Ok(())
2329    }
2330}
2331
2332/// A connection to an AWS Glue Schema Registry.
2333///
2334/// AWS credentials, region, and endpoint are inherited from the referenced
2335/// [`AwsConnection`]; this struct only carries the per-registry settings.
2336#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2337pub struct GlueSchemaRegistryConnection<C: ConnectionAccess = InlinedConnection> {
2338    /// The referenced AWS connection that supplies credentials, region, and
2339    /// (optional) endpoint.
2340    pub aws_connection: AwsConnectionReference<C>,
2341    /// The Glue Schema Registry name within the AWS account/region.
2342    pub registry_name: String,
2343}
2344
2345impl<R: ConnectionResolver> IntoInlineConnection<GlueSchemaRegistryConnection, R>
2346    for GlueSchemaRegistryConnection<ReferencedConnection>
2347{
2348    fn into_inline_connection(self, r: R) -> GlueSchemaRegistryConnection {
2349        let GlueSchemaRegistryConnection {
2350            aws_connection,
2351            registry_name,
2352        } = self;
2353        GlueSchemaRegistryConnection {
2354            aws_connection: aws_connection.into_inline_connection(&r),
2355            registry_name,
2356        }
2357    }
2358}
2359
2360impl<C: ConnectionAccess> GlueSchemaRegistryConnection<C> {
2361    fn validate_by_default(&self) -> bool {
2362        // Matches CSR: default-validate so a bad registry name fails at
2363        // `CREATE CONNECTION` rather than surfacing later on first use.
2364        // Users can still opt out with `WITH (VALIDATE = false)`.
2365        true
2366    }
2367}
2368
2369impl GlueSchemaRegistryConnection {
2370    async fn validate(
2371        &self,
2372        _id: CatalogItemId,
2373        storage_configuration: &StorageConfiguration,
2374    ) -> Result<(), anyhow::Error> {
2375        let enforce_external_addresses =
2376            crate::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set());
2377        let sdk_config = self
2378            .aws_connection
2379            .connection
2380            .load_sdk_config(
2381                &storage_configuration.connection_context,
2382                self.aws_connection.connection_id,
2383                // We are in a normal tokio context during validation.
2384                InTask::No,
2385                enforce_external_addresses,
2386            )
2387            .await?;
2388        let client = mz_aws_glue_schema_registry::ClientConfig::new(sdk_config).build();
2389        match client.get_registry(&self.registry_name).await {
2390            Ok(_) => Ok(()),
2391            Err(mz_aws_glue_schema_registry::GetRegistryError::NotFound) => Err(anyhow!(
2392                "AWS Glue Schema Registry {:?} does not exist in the configured account/region",
2393                self.registry_name
2394            )),
2395            Err(err) => Err(anyhow::Error::new(err).context(format!(
2396                "failed to validate AWS Glue Schema Registry connection (registry={:?})",
2397                self.registry_name
2398            ))),
2399        }
2400    }
2401}
2402
2403impl<C: ConnectionAccess> AlterCompatible for GlueSchemaRegistryConnection<C> {
2404    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2405        let GlueSchemaRegistryConnection {
2406            registry_name,
2407            // The referenced AWS connection itself may be swapped; matches
2408            // the permissive policy of MySqlConnection / SqlServerConnection.
2409            aws_connection: _,
2410        } = self;
2411
2412        let compatibility_checks = [(registry_name == &other.registry_name, "registry_name")];
2413
2414        for (compatible, field) in compatibility_checks {
2415            if !compatible {
2416                tracing::warn!(
2417                    "GlueSchemaRegistryConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2418                    self,
2419                    other
2420                );
2421
2422                return Err(AlterError { id });
2423            }
2424        }
2425        Ok(())
2426    }
2427}
2428
2429/// A TLS key pair used for client identity.
2430#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2431pub struct TlsIdentity {
2432    /// The client's TLS public certificate in PEM format.
2433    pub cert: StringOrSecret,
2434    /// The ID of the secret containing the client's TLS private key in PEM
2435    /// format.
2436    pub key: CatalogItemId,
2437}
2438
2439/// HTTP authentication credentials in a [`CsrConnection`].
2440#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2441pub struct CsrConnectionHttpAuth {
2442    /// The username.
2443    pub username: StringOrSecret,
2444    /// The ID of the secret containing the password, if any.
2445    pub password: Option<CatalogItemId>,
2446}
2447
2448/// A connection to a PostgreSQL server.
2449#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2450pub struct PostgresConnection<C: ConnectionAccess = InlinedConnection> {
2451    /// The hostname of the server.
2452    pub host: String,
2453    /// The port of the server.
2454    pub port: u16,
2455    /// The name of the database to connect to.
2456    pub database: String,
2457    /// The username to authenticate as.
2458    pub user: StringOrSecret,
2459    /// An optional password for authentication.
2460    pub password: Option<CatalogItemId>,
2461    /// A tunnel through which to route traffic.
2462    pub tunnel: Tunnel<C>,
2463    /// Whether to use TLS for encryption, authentication, or both.
2464    pub tls_mode: SslMode,
2465    /// An optional root TLS certificate in PEM format, to verify the server's
2466    /// identity.
2467    pub tls_root_cert: Option<StringOrSecret>,
2468    /// An optional TLS client certificate for authentication.
2469    pub tls_identity: Option<TlsIdentity>,
2470}
2471
2472impl<R: ConnectionResolver> IntoInlineConnection<PostgresConnection, R>
2473    for PostgresConnection<ReferencedConnection>
2474{
2475    fn into_inline_connection(self, r: R) -> PostgresConnection {
2476        let PostgresConnection {
2477            host,
2478            port,
2479            database,
2480            user,
2481            password,
2482            tunnel,
2483            tls_mode,
2484            tls_root_cert,
2485            tls_identity,
2486        } = self;
2487
2488        PostgresConnection {
2489            host,
2490            port,
2491            database,
2492            user,
2493            password,
2494            tunnel: tunnel.into_inline_connection(r),
2495            tls_mode,
2496            tls_root_cert,
2497            tls_identity,
2498        }
2499    }
2500}
2501
2502impl<C: ConnectionAccess> PostgresConnection<C> {
2503    fn validate_by_default(&self) -> bool {
2504        true
2505    }
2506}
2507
2508impl PostgresConnection<InlinedConnection> {
2509    pub async fn config(
2510        &self,
2511        secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2512        storage_configuration: &StorageConfiguration,
2513        in_task: InTask,
2514    ) -> Result<mz_postgres_util::Config, anyhow::Error> {
2515        let params = &storage_configuration.parameters;
2516
2517        let mut config = tokio_postgres::Config::new();
2518        config
2519            .host(&self.host)
2520            .port(self.port)
2521            .dbname(&self.database)
2522            .user(&self.user.get_string(in_task, secrets_reader).await?)
2523            .ssl_mode(self.tls_mode)
2524            .application_name("materialize");
2525        if let Some(password) = self.password {
2526            let password = secrets_reader
2527                .read_string_in_task_if(in_task, password)
2528                .await?;
2529            config.password(password);
2530        }
2531        if let Some(tls_root_cert) = &self.tls_root_cert {
2532            let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2533            config.ssl_root_cert(tls_root_cert.as_bytes());
2534        }
2535        if let Some(tls_identity) = &self.tls_identity {
2536            let cert = tls_identity
2537                .cert
2538                .get_string(in_task, secrets_reader)
2539                .await?;
2540            let key = secrets_reader
2541                .read_string_in_task_if(in_task, tls_identity.key)
2542                .await?;
2543            config.ssl_cert(cert.as_bytes()).ssl_key(key.as_bytes());
2544        }
2545
2546        if let Some(connect_timeout) = params.pg_source_connect_timeout {
2547            config.connect_timeout(connect_timeout);
2548        }
2549        if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2550            config.keepalives_retries(keepalives_retries);
2551        }
2552        if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2553            config.keepalives_idle(keepalives_idle);
2554        }
2555        if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2556            config.keepalives_interval(keepalives_interval);
2557        }
2558        if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2559            config.tcp_user_timeout(tcp_user_timeout);
2560        }
2561
2562        let mut options = vec![];
2563        if let Some(wal_sender_timeout) = params.pg_source_wal_sender_timeout {
2564            options.push(format!(
2565                "--wal_sender_timeout={}",
2566                wal_sender_timeout.as_millis()
2567            ));
2568        };
2569        if params.pg_source_tcp_configure_server {
2570            if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2571                options.push(format!("--tcp_keepalives_count={}", keepalives_retries));
2572            }
2573            if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2574                options.push(format!(
2575                    "--tcp_keepalives_idle={}",
2576                    keepalives_idle.as_secs()
2577                ));
2578            }
2579            if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2580                options.push(format!(
2581                    "--tcp_keepalives_interval={}",
2582                    keepalives_interval.as_secs()
2583                ));
2584            }
2585            if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2586                options.push(format!(
2587                    "--tcp_user_timeout={}",
2588                    tcp_user_timeout.as_millis()
2589                ));
2590            }
2591        }
2592        config.options(options.join(" ").as_str());
2593
2594        let tunnel = match &self.tunnel {
2595            Tunnel::Direct => {
2596                // Ensure any host we connect to is resolved to an external address.
2597                let resolved = resolve_address(
2598                    &self.host,
2599                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2600                )
2601                .await?;
2602                mz_postgres_util::TunnelConfig::Direct {
2603                    resolved_ips: Some(resolved),
2604                }
2605            }
2606            Tunnel::Ssh(SshTunnel {
2607                connection_id,
2608                connection,
2609            }) => {
2610                let secret = secrets_reader
2611                    .read_in_task_if(in_task, *connection_id)
2612                    .await?;
2613                let key_pair = SshKeyPair::from_bytes(&secret)?;
2614                // Ensure any ssh-bastion host we connect to is resolved to an external address.
2615                let resolved = resolve_address(
2616                    &connection.host,
2617                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2618                )
2619                .await?;
2620                mz_postgres_util::TunnelConfig::Ssh {
2621                    config: SshTunnelConfig {
2622                        host: resolved
2623                            .iter()
2624                            .map(|a| a.to_string())
2625                            .collect::<BTreeSet<_>>(),
2626                        port: connection.port,
2627                        user: connection.user.clone(),
2628                        key_pair,
2629                    },
2630                }
2631            }
2632            Tunnel::AwsPrivatelink(connection) => {
2633                assert_none!(connection.port);
2634                mz_postgres_util::TunnelConfig::AwsPrivatelink {
2635                    connection_id: connection.connection_id,
2636                }
2637            }
2638            Tunnel::AwsPrivatelinks(_) => {
2639                unreachable!("MATCHING broker rules are only available for Kafka connections.");
2640            }
2641        };
2642
2643        Ok(mz_postgres_util::Config::new(
2644            config,
2645            tunnel,
2646            params.ssh_timeout_config,
2647            in_task,
2648        )?)
2649    }
2650
2651    pub async fn validate(
2652        &self,
2653        _id: CatalogItemId,
2654        storage_configuration: &StorageConfiguration,
2655    ) -> Result<mz_postgres_util::Client, anyhow::Error> {
2656        let config = self
2657            .config(
2658                &storage_configuration.connection_context.secrets_reader,
2659                storage_configuration,
2660                // We are in a normal tokio context during validation, already.
2661                InTask::No,
2662            )
2663            .await?;
2664        let client = config
2665            .connect(
2666                "connection validation",
2667                &storage_configuration.connection_context.ssh_tunnel_manager,
2668            )
2669            .await?;
2670
2671        let wal_level = mz_postgres_util::get_wal_level(&client).await?;
2672
2673        if wal_level < mz_postgres_util::replication::WalLevel::Logical {
2674            Err(PostgresConnectionValidationError::InsufficientWalLevel { wal_level })?;
2675        }
2676
2677        let max_wal_senders = mz_postgres_util::get_max_wal_senders(&client).await?;
2678
2679        if max_wal_senders < 1 {
2680            Err(PostgresConnectionValidationError::ReplicationDisabled)?;
2681        }
2682
2683        let available_replication_slots =
2684            mz_postgres_util::available_replication_slots(&client).await?;
2685
2686        // We need 1 replication slot for the snapshots and 1 for the continuing replication
2687        if available_replication_slots < 2 {
2688            Err(
2689                PostgresConnectionValidationError::InsufficientReplicationSlotsAvailable {
2690                    count: 2,
2691                },
2692            )?;
2693        }
2694
2695        Ok(client)
2696    }
2697}
2698
2699#[derive(Debug, Clone, thiserror::Error)]
2700pub enum PostgresConnectionValidationError {
2701    #[error("PostgreSQL server has insufficient number of replication slots available")]
2702    InsufficientReplicationSlotsAvailable { count: usize },
2703    #[error("server must have wal_level >= logical, but has {wal_level}")]
2704    InsufficientWalLevel {
2705        wal_level: mz_postgres_util::replication::WalLevel,
2706    },
2707    #[error("replication disabled on server")]
2708    ReplicationDisabled,
2709}
2710
2711impl PostgresConnectionValidationError {
2712    pub fn detail(&self) -> Option<String> {
2713        match self {
2714            Self::InsufficientReplicationSlotsAvailable { count } => Some(format!(
2715                "executing this statement requires {} replication slot{}",
2716                count,
2717                if *count == 1 { "" } else { "s" }
2718            )),
2719            _ => None,
2720        }
2721    }
2722
2723    pub fn hint(&self) -> Option<String> {
2724        match self {
2725            Self::InsufficientReplicationSlotsAvailable { .. } => Some(
2726                "you might be able to wait for other sources to finish snapshotting and try again"
2727                    .into(),
2728            ),
2729            Self::ReplicationDisabled => Some("set max_wal_senders to a value > 0".into()),
2730            Self::InsufficientWalLevel { .. } => None,
2731        }
2732    }
2733}
2734
2735impl<C: ConnectionAccess> AlterCompatible for PostgresConnection<C> {
2736    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2737        let PostgresConnection {
2738            tunnel,
2739            // All non-tunnel options may change arbitrarily
2740            host: _,
2741            port: _,
2742            database: _,
2743            user: _,
2744            password: _,
2745            tls_mode: _,
2746            tls_root_cert: _,
2747            tls_identity: _,
2748        } = self;
2749
2750        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2751
2752        for (compatible, field) in compatibility_checks {
2753            if !compatible {
2754                tracing::warn!(
2755                    "PostgresConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2756                    self,
2757                    other
2758                );
2759
2760                return Err(AlterError { id });
2761            }
2762        }
2763        Ok(())
2764    }
2765}
2766
2767/// Specifies how to tunnel a connection.
2768#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2769pub enum Tunnel<C: ConnectionAccess = InlinedConnection> {
2770    /// No tunneling.
2771    Direct,
2772    /// Via the specified SSH tunnel connection.
2773    Ssh(SshTunnel<C>),
2774    /// Via the specified AWS PrivateLink connection.
2775    AwsPrivatelink(AwsPrivatelink),
2776    AwsPrivatelinks(AwsPrivatelinks),
2777}
2778
2779impl<R: ConnectionResolver> IntoInlineConnection<Tunnel, R> for Tunnel<ReferencedConnection> {
2780    fn into_inline_connection(self, r: R) -> Tunnel {
2781        match self {
2782            Tunnel::Direct => Tunnel::Direct,
2783            Tunnel::Ssh(ssh) => Tunnel::Ssh(ssh.into_inline_connection(r)),
2784            Tunnel::AwsPrivatelink(awspl) => Tunnel::AwsPrivatelink(awspl),
2785            Tunnel::AwsPrivatelinks(x) => Tunnel::AwsPrivatelinks(x),
2786        }
2787    }
2788}
2789
2790impl<C: ConnectionAccess> AlterCompatible for Tunnel<C> {
2791    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2792        let compatible = match (self, other) {
2793            (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o).is_ok(),
2794            (s, o) => s == o,
2795        };
2796
2797        if !compatible {
2798            tracing::warn!(
2799                "Tunnel incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
2800                self,
2801                other
2802            );
2803
2804            return Err(AlterError { id });
2805        }
2806
2807        Ok(())
2808    }
2809}
2810
2811/// Specifies which MySQL SSL Mode to use:
2812/// <https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-mode>
2813/// This is not available as an enum in the mysql-async crate, so we define our own.
2814#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2815pub enum MySqlSslMode {
2816    Disabled,
2817    Required,
2818    VerifyCa,
2819    VerifyIdentity,
2820}
2821
2822/// A connection to a MySQL server.
2823#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2824pub struct MySqlConnection<C: ConnectionAccess = InlinedConnection> {
2825    /// The hostname of the server.
2826    pub host: String,
2827    /// The port of the server.
2828    pub port: u16,
2829    /// The username to authenticate as.
2830    pub user: StringOrSecret,
2831    /// An optional password for authentication.
2832    pub password: Option<CatalogItemId>,
2833    /// A tunnel through which to route traffic.
2834    pub tunnel: Tunnel<C>,
2835    /// Whether to use TLS for encryption, verify the server's certificate, and identity.
2836    pub tls_mode: MySqlSslMode,
2837    /// An optional root TLS certificate in PEM format, to verify the server's
2838    /// identity.
2839    pub tls_root_cert: Option<StringOrSecret>,
2840    /// An optional TLS client certificate for authentication.
2841    pub tls_identity: Option<TlsIdentity>,
2842    /// Reference to the AWS connection information to be used for IAM authenitcation and
2843    /// assuming AWS roles.
2844    pub aws_connection: Option<AwsConnectionReference<C>>,
2845}
2846
2847impl<R: ConnectionResolver> IntoInlineConnection<MySqlConnection, R>
2848    for MySqlConnection<ReferencedConnection>
2849{
2850    fn into_inline_connection(self, r: R) -> MySqlConnection {
2851        let MySqlConnection {
2852            host,
2853            port,
2854            user,
2855            password,
2856            tunnel,
2857            tls_mode,
2858            tls_root_cert,
2859            tls_identity,
2860            aws_connection,
2861        } = self;
2862
2863        MySqlConnection {
2864            host,
2865            port,
2866            user,
2867            password,
2868            tunnel: tunnel.into_inline_connection(&r),
2869            tls_mode,
2870            tls_root_cert,
2871            tls_identity,
2872            aws_connection: aws_connection.map(|aws| aws.into_inline_connection(&r)),
2873        }
2874    }
2875}
2876
2877impl<C: ConnectionAccess> MySqlConnection<C> {
2878    fn validate_by_default(&self) -> bool {
2879        true
2880    }
2881}
2882
2883impl MySqlConnection<InlinedConnection> {
2884    pub async fn config(
2885        &self,
2886        secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2887        storage_configuration: &StorageConfiguration,
2888        in_task: InTask,
2889    ) -> Result<mz_mysql_util::Config, anyhow::Error> {
2890        // TODO(roshan): Set appropriate connection timeouts
2891        let mut opts = mysql_async::OptsBuilder::default()
2892            .ip_or_hostname(&self.host)
2893            .tcp_port(self.port)
2894            .user(Some(&self.user.get_string(in_task, secrets_reader).await?));
2895
2896        if let Some(password) = self.password {
2897            let password = secrets_reader
2898                .read_string_in_task_if(in_task, password)
2899                .await?;
2900            opts = opts.pass(Some(password));
2901        }
2902
2903        // Our `MySqlSslMode` enum matches the official MySQL Client `--ssl-mode` parameter values
2904        // which uses opt-in security features (SSL, CA verification, & Identity verification).
2905        // The mysql_async crate `SslOpts` struct uses an opt-out mechanism for each of these, so
2906        // we need to appropriately disable features to match the intent of each enum value.
2907        let mut ssl_opts = match self.tls_mode {
2908            MySqlSslMode::Disabled => None,
2909            MySqlSslMode::Required => Some(
2910                mysql_async::SslOpts::default()
2911                    .with_danger_accept_invalid_certs(true)
2912                    .with_danger_skip_domain_validation(true),
2913            ),
2914            MySqlSslMode::VerifyCa => {
2915                Some(mysql_async::SslOpts::default().with_danger_skip_domain_validation(true))
2916            }
2917            MySqlSslMode::VerifyIdentity => Some(mysql_async::SslOpts::default()),
2918        };
2919
2920        if matches!(
2921            self.tls_mode,
2922            MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity
2923        ) {
2924            // Trust the platform's roots, not the Mozilla set compiled into
2925            // mysql_async, so a distrusted root goes away with an OS update.
2926            let native = rustls_native_certs::load_native_certs();
2927            for error in &native.errors {
2928                tracing::warn!("failed to load native root certs: {error}");
2929            }
2930            let mut roots: Vec<_> = native
2931                .certs
2932                .into_iter()
2933                .map(|cert| cert.to_vec().into())
2934                .collect();
2935            if let Some(tls_root_cert) = &self.tls_root_cert {
2936                let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2937                roots.push(tls_root_cert.into_bytes().into());
2938            }
2939            ssl_opts = ssl_opts.map(|opts| {
2940                opts.with_disable_built_in_roots(true)
2941                    .with_root_certs(roots)
2942            });
2943        }
2944
2945        if let Some(identity) = &self.tls_identity {
2946            let key = secrets_reader
2947                .read_string_in_task_if(in_task, identity.key)
2948                .await?;
2949            let cert = identity.cert.get_string(in_task, secrets_reader).await?;
2950            ssl_opts = ssl_opts.map(|opts| {
2951                opts.with_client_identity(Some(mysql_async::ClientIdentity::new(
2952                    cert.into_bytes().into(),
2953                    key.into_bytes().into(),
2954                )))
2955            });
2956        }
2957
2958        opts = opts.ssl_opts(ssl_opts);
2959
2960        let tunnel = match &self.tunnel {
2961            Tunnel::Direct => {
2962                // Ensure any host we connect to is resolved to an external address.
2963                let resolved = resolve_address(
2964                    &self.host,
2965                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2966                )
2967                .await?;
2968                mz_mysql_util::TunnelConfig::Direct {
2969                    resolved_ips: Some(resolved),
2970                }
2971            }
2972            Tunnel::Ssh(SshTunnel {
2973                connection_id,
2974                connection,
2975            }) => {
2976                let secret = secrets_reader
2977                    .read_in_task_if(in_task, *connection_id)
2978                    .await?;
2979                let key_pair = SshKeyPair::from_bytes(&secret)?;
2980                // Ensure any ssh-bastion host we connect to is resolved to an external address.
2981                let resolved = resolve_address(
2982                    &connection.host,
2983                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2984                )
2985                .await?;
2986                mz_mysql_util::TunnelConfig::Ssh {
2987                    config: SshTunnelConfig {
2988                        host: resolved
2989                            .iter()
2990                            .map(|a| a.to_string())
2991                            .collect::<BTreeSet<_>>(),
2992                        port: connection.port,
2993                        user: connection.user.clone(),
2994                        key_pair,
2995                    },
2996                }
2997            }
2998            Tunnel::AwsPrivatelink(connection) => {
2999                assert_none!(connection.port);
3000                mz_mysql_util::TunnelConfig::AwsPrivatelink {
3001                    connection_id: connection.connection_id,
3002                }
3003            }
3004            Tunnel::AwsPrivatelinks(_) => {
3005                unreachable!("MATCHING broker rules are only available for Kafka connections.");
3006            }
3007        };
3008
3009        let aws_config = match self.aws_connection.as_ref() {
3010            None => None,
3011            Some(aws_ref) => Some(
3012                aws_ref
3013                    .connection
3014                    .load_sdk_config(
3015                        &storage_configuration.connection_context,
3016                        aws_ref.connection_id,
3017                        in_task,
3018                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3019                    )
3020                    .await?,
3021            ),
3022        };
3023
3024        Ok(mz_mysql_util::Config::new(
3025            opts,
3026            tunnel,
3027            storage_configuration.parameters.ssh_timeout_config,
3028            in_task,
3029            storage_configuration
3030                .parameters
3031                .mysql_source_timeouts
3032                .clone(),
3033            aws_config,
3034        )?)
3035    }
3036
3037    pub async fn validate(
3038        &self,
3039        _id: CatalogItemId,
3040        storage_configuration: &StorageConfiguration,
3041    ) -> Result<MySqlConn, MySqlConnectionValidationError> {
3042        let config = self
3043            .config(
3044                &storage_configuration.connection_context.secrets_reader,
3045                storage_configuration,
3046                // We are in a normal tokio context during validation, already.
3047                InTask::No,
3048            )
3049            .await?;
3050        let mut conn = config
3051            .connect(
3052                "connection validation",
3053                &storage_configuration.connection_context.ssh_tunnel_manager,
3054            )
3055            .await?;
3056
3057        // Check if the MySQL database is configured to allow row-based consistent GTID replication
3058        let mut setting_errors = vec![];
3059        let gtid_res = mz_mysql_util::ensure_gtid_consistency(&mut conn).await;
3060        let binlog_res = mz_mysql_util::ensure_full_row_binlog_format(&mut conn).await;
3061        let order_res = mz_mysql_util::ensure_replication_commit_order(&mut conn).await;
3062        for res in [gtid_res, binlog_res, order_res] {
3063            match res {
3064                Err(MySqlError::InvalidSystemSetting {
3065                    setting,
3066                    expected,
3067                    actual,
3068                }) => {
3069                    setting_errors.push((setting, expected, actual));
3070                }
3071                Err(err) => Err(err)?,
3072                Ok(()) => {}
3073            }
3074        }
3075        if !setting_errors.is_empty() {
3076            Err(MySqlConnectionValidationError::ReplicationSettingsError(
3077                setting_errors,
3078            ))?;
3079        }
3080
3081        Ok(conn)
3082    }
3083}
3084
3085#[derive(Debug, thiserror::Error)]
3086pub enum MySqlConnectionValidationError {
3087    #[error("Invalid MySQL system replication settings")]
3088    ReplicationSettingsError(Vec<(String, String, String)>),
3089    #[error(transparent)]
3090    Client(#[from] MySqlError),
3091    #[error("{}", .0.display_with_causes())]
3092    Other(#[from] anyhow::Error),
3093}
3094
3095impl MySqlConnectionValidationError {
3096    pub fn detail(&self) -> Option<String> {
3097        match self {
3098            Self::ReplicationSettingsError(settings) => Some(format!(
3099                "Invalid MySQL system replication settings: {}",
3100                itertools::join(
3101                    settings.iter().map(|(setting, expected, actual)| format!(
3102                        "{}: expected {}, got {}",
3103                        setting, expected, actual
3104                    )),
3105                    "; "
3106                )
3107            )),
3108            _ => None,
3109        }
3110    }
3111
3112    pub fn hint(&self) -> Option<String> {
3113        match self {
3114            Self::ReplicationSettingsError(_) => {
3115                Some("Set the necessary MySQL database system settings.".into())
3116            }
3117            _ => None,
3118        }
3119    }
3120}
3121
3122impl<C: ConnectionAccess> AlterCompatible for MySqlConnection<C> {
3123    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3124        let MySqlConnection {
3125            tunnel,
3126            // All non-tunnel options may change arbitrarily
3127            host: _,
3128            port: _,
3129            user: _,
3130            password: _,
3131            tls_mode: _,
3132            tls_root_cert: _,
3133            tls_identity: _,
3134            aws_connection: _,
3135        } = self;
3136
3137        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
3138
3139        for (compatible, field) in compatibility_checks {
3140            if !compatible {
3141                tracing::warn!(
3142                    "MySqlConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3143                    self,
3144                    other
3145                );
3146
3147                return Err(AlterError { id });
3148            }
3149        }
3150        Ok(())
3151    }
3152}
3153
3154/// Details how to connect to an instance of Microsoft SQL Server.
3155///
3156/// For specifics of connecting to SQL Server for purposes of creating a
3157/// Materialize Source, see [`SqlServerSourceConnection`] which wraps this type.
3158///
3159/// [`SqlServerSourceConnection`]: crate::sources::SqlServerSourceConnection
3160#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3161pub struct SqlServerConnectionDetails<C: ConnectionAccess = InlinedConnection> {
3162    /// The hostname of the server.
3163    pub host: String,
3164    /// The port of the server.
3165    pub port: u16,
3166    /// Database we should connect to.
3167    pub database: String,
3168    /// The username to authenticate as.
3169    pub user: StringOrSecret,
3170    /// Password used for authentication.
3171    pub password: CatalogItemId,
3172    /// A tunnel through which to route traffic.
3173    pub tunnel: Tunnel<C>,
3174    /// Level of encryption to use for the connection.
3175    pub encryption: mz_sql_server_util::config::EncryptionLevel,
3176    /// Certificate validation policy
3177    pub certificate_validation_policy: mz_sql_server_util::config::CertificateValidationPolicy,
3178    /// TLS CA Certifiecate in PEM format
3179    pub tls_root_cert: Option<StringOrSecret>,
3180}
3181
3182impl<C: ConnectionAccess> SqlServerConnectionDetails<C> {
3183    fn validate_by_default(&self) -> bool {
3184        true
3185    }
3186}
3187
3188impl SqlServerConnectionDetails<InlinedConnection> {
3189    /// Attempts to open a connection to the upstream SQL Server instance.
3190    pub async fn validate(
3191        &self,
3192        _id: CatalogItemId,
3193        storage_configuration: &StorageConfiguration,
3194    ) -> Result<mz_sql_server_util::Client, anyhow::Error> {
3195        let config = self
3196            .resolve_config(
3197                &storage_configuration.connection_context.secrets_reader,
3198                storage_configuration,
3199                InTask::No,
3200            )
3201            .await?;
3202        tracing::debug!(?config, "Validating SQL Server connection");
3203
3204        let mut client = mz_sql_server_util::Client::connect(config).await?;
3205
3206        // Ensure the upstream SQL Server instance is configured to allow CDC.
3207        //
3208        // Run all of the checks necessary and collect the errors to provide the best
3209        // guidance as to which system settings need to be enabled.
3210        let mut replication_errors = vec![];
3211        for error in [
3212            mz_sql_server_util::inspect::ensure_database_cdc_enabled(&mut client).await,
3213            mz_sql_server_util::inspect::ensure_snapshot_isolation_enabled(&mut client).await,
3214            mz_sql_server_util::inspect::ensure_sql_server_agent_running(&mut client).await,
3215        ] {
3216            match error {
3217                Err(mz_sql_server_util::SqlServerError::InvalidSystemSetting {
3218                    name,
3219                    expected,
3220                    actual,
3221                }) => replication_errors.push((name, expected, actual)),
3222                Err(other) => Err(other)?,
3223                Ok(()) => (),
3224            }
3225        }
3226        if !replication_errors.is_empty() {
3227            Err(SqlServerConnectionValidationError::ReplicationSettingsError(replication_errors))?;
3228        }
3229
3230        Ok(client)
3231    }
3232
3233    /// Resolve all of the connection details (e.g. read from the [`SecretsReader`])
3234    /// so the returned [`Config`] can be used to open a connection with the
3235    /// upstream system.
3236    ///
3237    /// The provided [`InTask`] argument determines whether any I/O is run in an
3238    /// [`mz_ore::task`] (i.e. a different thread) or directly in the returned
3239    /// future. The main goal here is to prevent running I/O in timely threads.
3240    ///
3241    /// [`Config`]: mz_sql_server_util::Config
3242    pub async fn resolve_config(
3243        &self,
3244        secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
3245        storage_configuration: &StorageConfiguration,
3246        in_task: InTask,
3247    ) -> Result<mz_sql_server_util::Config, anyhow::Error> {
3248        let dyncfg = storage_configuration.config_set();
3249        let mut inner_config = tiberius::Config::new();
3250
3251        // Setup default connection params.
3252        inner_config.host(&self.host);
3253        inner_config.port(self.port);
3254        inner_config.database(self.database.clone());
3255        inner_config.encryption(self.encryption.into());
3256        match self.certificate_validation_policy {
3257            mz_sql_server_util::config::CertificateValidationPolicy::TrustAll => {
3258                inner_config.trust_cert()
3259            }
3260            mz_sql_server_util::config::CertificateValidationPolicy::VerifyCA => {
3261                inner_config.trust_cert_ca_pem(
3262                    self.tls_root_cert
3263                        .as_ref()
3264                        .unwrap()
3265                        .get_string(in_task, secrets_reader)
3266                        .await
3267                        .context("ca certificate")?,
3268                );
3269            }
3270            mz_sql_server_util::config::CertificateValidationPolicy::VerifySystem => (), // no-op
3271        }
3272
3273        inner_config.application_name("materialize");
3274
3275        // Read our auth settings from
3276        let user = self
3277            .user
3278            .get_string(in_task, secrets_reader)
3279            .await
3280            .context("username")?;
3281        let password = secrets_reader
3282            .read_string_in_task_if(in_task, self.password)
3283            .await
3284            .context("password")?;
3285        // TODO(sql_server3): Support other methods of authentication besides
3286        // username and password.
3287        inner_config.authentication(tiberius::AuthMethod::sql_server(user, password));
3288
3289        // Prevent users from probing our internal network ports by trying to
3290        // connect to localhost, or another non-external IP.
3291        let enforce_external_addresses = ENFORCE_EXTERNAL_ADDRESSES.get(dyncfg);
3292
3293        let tunnel = match &self.tunnel {
3294            Tunnel::Direct => {
3295                let resolved_addresses: Vec<SocketAddr> =
3296                    resolve_address(&self.host, enforce_external_addresses)
3297                        .await?
3298                        .into_iter()
3299                        .map(|ip| SocketAddr::new(ip, self.port))
3300                        .collect();
3301                mz_sql_server_util::config::TunnelConfig::Direct {
3302                    resolved_addresses: resolved_addresses.into_boxed_slice(),
3303                }
3304            }
3305            Tunnel::Ssh(SshTunnel {
3306                connection_id,
3307                connection: ssh_connection,
3308            }) => {
3309                let secret = secrets_reader
3310                    .read_in_task_if(in_task, *connection_id)
3311                    .await
3312                    .context("ssh secret")?;
3313                let key_pair = SshKeyPair::from_bytes(&secret).context("ssh key pair")?;
3314                // Ensure any SSH-bastion host we connect to is resolved to an
3315                // external address.
3316                let addresses = resolve_address(&ssh_connection.host, enforce_external_addresses)
3317                    .await
3318                    .context("ssh tunnel")?;
3319
3320                let config = SshTunnelConfig {
3321                    host: addresses.into_iter().map(|a| a.to_string()).collect(),
3322                    port: ssh_connection.port,
3323                    user: ssh_connection.user.clone(),
3324                    key_pair,
3325                };
3326                mz_sql_server_util::config::TunnelConfig::Ssh {
3327                    config,
3328                    manager: storage_configuration
3329                        .connection_context
3330                        .ssh_tunnel_manager
3331                        .clone(),
3332                    timeout: storage_configuration.parameters.ssh_timeout_config.clone(),
3333                    host: self.host.clone(),
3334                    port: self.port,
3335                }
3336            }
3337            Tunnel::AwsPrivatelink(private_link_connection) => {
3338                assert_none!(private_link_connection.port);
3339                mz_sql_server_util::config::TunnelConfig::AwsPrivatelink {
3340                    connection_id: private_link_connection.connection_id,
3341                    port: self.port,
3342                }
3343            }
3344            Tunnel::AwsPrivatelinks(_) => {
3345                unreachable!("MATCHING broker rules are only available for Kafka connections.");
3346            }
3347        };
3348
3349        Ok(mz_sql_server_util::Config::new(
3350            inner_config,
3351            tunnel,
3352            in_task,
3353        ))
3354    }
3355}
3356
3357#[derive(Debug, Clone, thiserror::Error)]
3358pub enum SqlServerConnectionValidationError {
3359    #[error("Invalid SQL Server system replication settings")]
3360    ReplicationSettingsError(Vec<(String, String, String)>),
3361}
3362
3363impl SqlServerConnectionValidationError {
3364    pub fn detail(&self) -> Option<String> {
3365        match self {
3366            Self::ReplicationSettingsError(settings) => Some(format!(
3367                "Invalid SQL Server system replication settings: {}",
3368                itertools::join(
3369                    settings.iter().map(|(setting, expected, actual)| format!(
3370                        "{}: expected {}, got {}",
3371                        setting, expected, actual
3372                    )),
3373                    "; "
3374                )
3375            )),
3376        }
3377    }
3378
3379    pub fn hint(&self) -> Option<String> {
3380        match self {
3381            _ => None,
3382        }
3383    }
3384}
3385
3386impl<R: ConnectionResolver> IntoInlineConnection<SqlServerConnectionDetails, R>
3387    for SqlServerConnectionDetails<ReferencedConnection>
3388{
3389    fn into_inline_connection(self, r: R) -> SqlServerConnectionDetails {
3390        let SqlServerConnectionDetails {
3391            host,
3392            port,
3393            database,
3394            user,
3395            password,
3396            tunnel,
3397            encryption,
3398            certificate_validation_policy,
3399            tls_root_cert,
3400        } = self;
3401
3402        SqlServerConnectionDetails {
3403            host,
3404            port,
3405            database,
3406            user,
3407            password,
3408            tunnel: tunnel.into_inline_connection(&r),
3409            encryption,
3410            certificate_validation_policy,
3411            tls_root_cert,
3412        }
3413    }
3414}
3415
3416impl<C: ConnectionAccess> AlterCompatible for SqlServerConnectionDetails<C> {
3417    fn alter_compatible(
3418        &self,
3419        id: mz_repr::GlobalId,
3420        other: &Self,
3421    ) -> Result<(), crate::controller::AlterError> {
3422        let SqlServerConnectionDetails {
3423            tunnel,
3424            // TODO(sql_server2): Figure out how these variables are allowed to change.
3425            host: _,
3426            port: _,
3427            database: _,
3428            user: _,
3429            password: _,
3430            encryption: _,
3431            certificate_validation_policy: _,
3432            tls_root_cert: _,
3433        } = self;
3434
3435        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
3436
3437        for (compatible, field) in compatibility_checks {
3438            if !compatible {
3439                tracing::warn!(
3440                    "SqlServerConnectionDetails incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3441                    self,
3442                    other
3443                );
3444
3445                return Err(AlterError { id });
3446            }
3447        }
3448        Ok(())
3449    }
3450}
3451
3452/// A connection to an SSH tunnel.
3453#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3454pub struct SshConnection {
3455    pub host: String,
3456    pub port: u16,
3457    pub user: String,
3458}
3459
3460use self::inline::{
3461    ConnectionAccess, ConnectionResolver, InlinedConnection, IntoInlineConnection,
3462    ReferencedConnection,
3463};
3464
3465impl AlterCompatible for SshConnection {
3466    fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
3467        // Every element of the SSH connection is configurable.
3468        Ok(())
3469    }
3470}
3471
3472/// Specifies an AWS PrivateLink service for a [`Tunnel`].
3473#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3474pub struct AwsPrivatelink {
3475    /// The ID of the connection to the AWS PrivateLink service.
3476    pub connection_id: CatalogItemId,
3477    // The availability zone to use when connecting to the AWS PrivateLink service.
3478    pub availability_zone: Option<String>,
3479    /// The port to use when connecting to the AWS PrivateLink service, if
3480    /// different from the port in [`KafkaBroker::address`].
3481    pub port: Option<u16>,
3482}
3483
3484impl AlterCompatible for AwsPrivatelink {
3485    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3486        let AwsPrivatelink {
3487            connection_id,
3488            availability_zone: _,
3489            port: _,
3490        } = self;
3491
3492        let compatibility_checks = [(connection_id == &other.connection_id, "connection_id")];
3493
3494        for (compatible, field) in compatibility_checks {
3495            if !compatible {
3496                tracing::warn!(
3497                    "AwsPrivatelink incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3498                    self,
3499                    other
3500                );
3501
3502                return Err(AlterError { id });
3503            }
3504        }
3505
3506        Ok(())
3507    }
3508}
3509
3510#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3511pub struct AwsPrivatelinks {
3512    /// Route to brokers through PrivateLink connections according to these rules.
3513    /// Exact-match rules (no wildcards) are used as bootstrap brokers.
3514    /// Wildcard rules are applied dynamically to discovered brokers.
3515    pub rules: Vec<AwsPrivatelinkRule>,
3516}
3517
3518#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3519pub struct AwsPrivatelinkRule {
3520    /// Given a broker's host:port, should we use this route?
3521    pub pattern: ConnectionRulePattern,
3522    /// Route to the broker through this PrivateLink connection.
3523    pub to: AwsPrivatelink,
3524}
3525
3526/// Specifies an SSH tunnel connection.
3527#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3528pub struct SshTunnel<C: ConnectionAccess = InlinedConnection> {
3529    /// id of the ssh connection
3530    pub connection_id: CatalogItemId,
3531    /// ssh connection object
3532    pub connection: C::Ssh,
3533}
3534
3535impl<R: ConnectionResolver> IntoInlineConnection<SshTunnel, R> for SshTunnel<ReferencedConnection> {
3536    fn into_inline_connection(self, r: R) -> SshTunnel {
3537        let SshTunnel {
3538            connection,
3539            connection_id,
3540        } = self;
3541
3542        SshTunnel {
3543            connection: r.resolve_connection(connection).unwrap_ssh(),
3544            connection_id,
3545        }
3546    }
3547}
3548
3549impl SshTunnel<InlinedConnection> {
3550    /// Like [`SshTunnelConfig::connect`], but the SSH key is loaded from a
3551    /// secret.
3552    async fn connect(
3553        &self,
3554        storage_configuration: &StorageConfiguration,
3555        remote_host: &str,
3556        remote_port: u16,
3557        in_task: InTask,
3558    ) -> Result<ManagedSshTunnelHandle, anyhow::Error> {
3559        // Ensure any ssh-bastion host we connect to is resolved to an external address.
3560        let resolved = resolve_address(
3561            &self.connection.host,
3562            ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3563        )
3564        .await?;
3565        storage_configuration
3566            .connection_context
3567            .ssh_tunnel_manager
3568            .connect(
3569                SshTunnelConfig {
3570                    host: resolved
3571                        .iter()
3572                        .map(|a| a.to_string())
3573                        .collect::<BTreeSet<_>>(),
3574                    port: self.connection.port,
3575                    user: self.connection.user.clone(),
3576                    key_pair: SshKeyPair::from_bytes(
3577                        &storage_configuration
3578                            .connection_context
3579                            .secrets_reader
3580                            .read_in_task_if(in_task, self.connection_id)
3581                            .await?,
3582                    )?,
3583                },
3584                remote_host,
3585                remote_port,
3586                storage_configuration.parameters.ssh_timeout_config,
3587                in_task,
3588            )
3589            .await
3590    }
3591}
3592
3593impl<C: ConnectionAccess> AlterCompatible for SshTunnel<C> {
3594    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3595        let SshTunnel {
3596            connection_id,
3597            connection,
3598        } = self;
3599
3600        let compatibility_checks = [
3601            (connection_id == &other.connection_id, "connection_id"),
3602            (
3603                connection.alter_compatible(id, &other.connection).is_ok(),
3604                "connection",
3605            ),
3606        ];
3607
3608        for (compatible, field) in compatibility_checks {
3609            if !compatible {
3610                tracing::warn!(
3611                    "SshTunnel incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3612                    self,
3613                    other
3614                );
3615
3616                return Err(AlterError { id });
3617            }
3618        }
3619
3620        Ok(())
3621    }
3622}
3623
3624impl SshConnection {
3625    #[allow(clippy::unused_async)]
3626    async fn validate(
3627        &self,
3628        id: CatalogItemId,
3629        storage_configuration: &StorageConfiguration,
3630    ) -> Result<(), anyhow::Error> {
3631        let secret = storage_configuration
3632            .connection_context
3633            .secrets_reader
3634            .read_in_task_if(
3635                // We are in a normal tokio context during validation, already.
3636                InTask::No,
3637                id,
3638            )
3639            .await?;
3640        let key_pair = SshKeyPair::from_bytes(&secret)?;
3641
3642        // Ensure any ssh-bastion host we connect to is resolved to an external address.
3643        let resolved = resolve_address(
3644            &self.host,
3645            ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3646        )
3647        .await?;
3648
3649        let config = SshTunnelConfig {
3650            host: resolved
3651                .iter()
3652                .map(|a| a.to_string())
3653                .collect::<BTreeSet<_>>(),
3654            port: self.port,
3655            user: self.user.clone(),
3656            key_pair,
3657        };
3658        // Note that we do NOT use the `SshTunnelManager` here, as we want to validate that we
3659        // can actually create a new connection to the ssh bastion, without tunneling.
3660        config
3661            .validate(storage_configuration.parameters.ssh_timeout_config)
3662            .await
3663    }
3664
3665    fn validate_by_default(&self) -> bool {
3666        false
3667    }
3668}
3669
3670impl AwsPrivatelinkConnection {
3671    #[allow(clippy::unused_async)]
3672    async fn validate(
3673        &self,
3674        id: CatalogItemId,
3675        storage_configuration: &StorageConfiguration,
3676    ) -> Result<(), ConnectionValidationError> {
3677        // An endpoint for an unusable service name reports a misleading
3678        // condition (missing availability zones), so check the name first.
3679        Self::check_service_name(&self.service_name)?;
3680
3681        let Some(ref cloud_resource_reader) = storage_configuration
3682            .connection_context
3683            .cloud_resource_reader
3684        else {
3685            return Err(anyhow!("AWS PrivateLink connections are unsupported").into());
3686        };
3687
3688        // No need to optionally run this in a task, as we are just validating from envd.
3689        let status = cloud_resource_reader.read(id).await?;
3690
3691        let availability = status
3692            .conditions
3693            .as_ref()
3694            .and_then(|conditions| conditions.iter().find(|c| c.type_ == "Available"));
3695
3696        match availability {
3697            Some(condition) if condition.status == "True" => Ok(()),
3698            Some(condition) => Err(anyhow!("{}", condition.message).into()),
3699            None => Err(anyhow!("Endpoint availability is unknown").into()),
3700        }
3701    }
3702
3703    fn validate_by_default(&self) -> bool {
3704        false
3705    }
3706}
3707
3708#[cfg(test)]
3709mod tests {
3710    use super::*;
3711
3712    #[mz_ore::test]
3713    fn test_catalog_headers() {
3714        let props = BTreeMap::from_iter(
3715            [
3716                (REST_CATALOG_PROP_URI, "https://catalog.example"),
3717                (REST_CATALOG_PROP_WAREHOUSE, "wh"),
3718                ("header.x-goog-user-project", "some-project"),
3719                (REST_CATALOG_PROP_ACCESS_DELEGATION, "vended-credentials"),
3720            ]
3721            .map(|(k, v)| (k.to_string(), v.to_string())),
3722        );
3723
3724        // Only `header.*` props become headers, and the prefix is stripped. Header names are
3725        // matched case-insensitively, so the delegation prop's mixed-case spelling still lands.
3726        let headers = IcebergCatalogConnection::catalog_headers(&props).expect("valid headers");
3727        assert_eq!(headers.len(), 2);
3728        assert_eq!(headers["x-goog-user-project"], "some-project");
3729        assert_eq!(headers["x-iceberg-access-delegation"], "vended-credentials");
3730
3731        // A prop whose name is not a legal header is an error rather than a dropped header: it
3732        // would otherwise mean silently talking to the catalog differently than asked.
3733        let props = BTreeMap::from([("header.bad name".to_string(), "v".to_string())]);
3734        assert!(IcebergCatalogConnection::catalog_headers(&props).is_err());
3735    }
3736
3737    #[mz_ore::test]
3738    fn test_check_service_name() {
3739        // Customer-owned and AWS-managed endpoint services are both accepted,
3740        // as is anything else that could be an endpoint service name.
3741        for name in [
3742            "com.amazonaws.vpce.us-east-1.vpce-svc-0e123abc123198abc",
3743            "com.amazonaws.vpce.test.vpce-svc-e2e-test",
3744            "com.amazonaws.us-east-1.s3",
3745            "com.amazonaws.anything",
3746        ] {
3747            assert_eq!(
3748                AwsPrivatelinkConnection::check_service_name(name),
3749                Ok(()),
3750                "expected {name} to be accepted"
3751            );
3752        }
3753
3754        for name in [
3755            "",
3756            "com.amazonaws",
3757            "vpce-svc-0e123abc123198abc",
3758            "my-db-lb-0123456789abcdef.elb.eu-central-1.amazonaws.com",
3759            "db.internal.example.org",
3760        ] {
3761            let err = AwsPrivatelinkConnection::check_service_name(name)
3762                .expect_err("service name should be rejected");
3763            assert_eq!(err.name, name);
3764        }
3765    }
3766}