Skip to main content

mz_sql/plan/statement/ddl/
connection.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//! Data definition language (DDL) utilities for CONNECTION objects.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use anyhow::Context;
15use array_concat::concat_arrays;
16use itertools::Itertools;
17use maplit::btreemap;
18use mz_ore::num::NonNeg;
19use mz_ore::str::StrExt;
20use mz_repr::CatalogItemId;
21use mz_sql_parser::ast::ConnectionOptionName::*;
22use mz_sql_parser::ast::display::AstDisplay;
23use mz_sql_parser::ast::{
24    ConnectionDefaultAwsPrivatelink, ConnectionOption, ConnectionOptionName, CreateConnectionType,
25    KafkaBroker, KafkaBrokerAwsPrivatelinkOption, KafkaBrokerAwsPrivatelinkOptionName,
26    KafkaBrokerTunnel, KafkaMatchingBrokerRule,
27};
28use mz_ssh_util::keys::SshKeyPair;
29use mz_storage_types::connections::aws::{
30    AwsAssumeRole, AwsAuth, AwsConnection, AwsConnectionReference, AwsCredentials,
31};
32use mz_storage_types::connections::gcp::{GcpConnection, GcpConnectionReference};
33use mz_storage_types::connections::inline::ReferencedConnection;
34use mz_storage_types::connections::string_or_secret::StringOrSecret;
35use mz_storage_types::connections::{
36    AwsPrivatelink, AwsPrivatelinkConnection, AwsPrivatelinkRule, CsrConnection,
37    CsrConnectionHttpAuth, GlueSchemaRegistryConnection, IcebergAccessDelegation,
38    IcebergCatalogAuth, IcebergCatalogConnection, IcebergCatalogImpl, IcebergCatalogType,
39    IcebergStorageProvider, KafkaConnection, KafkaSaslConfig, KafkaTlsConfig, KafkaTopicOptions,
40    MySqlConnection, MySqlSslMode, PostgresConnection, RestIcebergCatalog,
41    S3TablesRestIcebergCatalog, SqlServerConnectionDetails, SshConnection, SshTunnel, TlsIdentity,
42    Tunnel,
43};
44
45use crate::names::Aug;
46use crate::plan::statement::{Connection, ResolvedItemName};
47use crate::plan::with_options::{self};
48use crate::plan::{ConnectionDetails, PlanError, SshKey, StatementContext};
49use crate::session::vars;
50
51generate_extracted_config!(
52    ConnectionOption,
53    (AccessDelegation, IcebergAccessDelegation),
54    (StorageProvider, IcebergStorageProvider),
55    (AccessKeyId, StringOrSecret),
56    (AssumeRoleArn, String),
57    (AssumeRoleSessionName, String),
58    (AvailabilityZones, Vec<String>),
59    (AwsConnection, with_options::Object),
60    (AwsPrivatelink, ConnectionDefaultAwsPrivatelink<Aug>),
61    (Broker, Vec<KafkaBroker<Aug>>),
62    (Brokers, with_options::BrokersList),
63    (Credential, StringOrSecret),
64    (Database, String),
65    (Endpoint, String),
66    (GcpConnection, with_options::Object),
67    (Host, String),
68    (Oauth2ServerUrl, String),
69    (Password, with_options::Secret),
70    (Port, u16),
71    (ProgressTopic, String),
72    (ProgressTopicReplicationFactor, i32),
73    (PublicKey1, String),
74    (PublicKey2, String),
75    (Region, String),
76    (Registry, String),
77    (SaslMechanisms, String),
78    (SaslPassword, with_options::Secret),
79    (SaslUsername, StringOrSecret),
80    (Scope, String),
81    (SecretAccessKey, with_options::Secret),
82    (SecurityProtocol, String),
83    (ServiceAccountKey, with_options::Secret),
84    (ServiceName, String),
85    (SshTunnel, with_options::Object),
86    (SslCertificate, StringOrSecret),
87    (SslCertificateAuthority, StringOrSecret),
88    (SslKey, with_options::Secret),
89    (SslMode, String),
90    (SessionToken, StringOrSecret),
91    (CatalogType, IcebergCatalogType),
92    (Url, String),
93    (User, StringOrSecret),
94    (Warehouse, String)
95);
96
97generate_extracted_config!(
98    KafkaBrokerAwsPrivatelinkOption,
99    (AvailabilityZone, String),
100    (Port, u16)
101);
102
103/// Options which cannot be changed using ALTER CONNECTION.
104pub(crate) const INALTERABLE_OPTIONS: &[ConnectionOptionName] =
105    &[ProgressTopic, ProgressTopicReplicationFactor];
106
107/// Options of which only one may be specified.
108pub(crate) const MUTUALLY_EXCLUSIVE_SETS: &[&[ConnectionOptionName]] = &[&[Broker, Brokers]];
109
110pub(super) fn validate_options_per_connection_type(
111    t: CreateConnectionType,
112    mut options: BTreeSet<ConnectionOptionName>,
113) -> Result<(), PlanError> {
114    use mz_sql_parser::ast::ConnectionOptionName::*;
115    let permitted_options = match t {
116        CreateConnectionType::Aws => [
117            AccessKeyId,
118            SecretAccessKey,
119            SessionToken,
120            Endpoint,
121            Region,
122            AssumeRoleArn,
123            AssumeRoleSessionName,
124        ]
125        .as_slice(),
126        CreateConnectionType::AwsPrivatelink => &[AvailabilityZones, Port, ServiceName],
127        CreateConnectionType::GlueSchemaRegistry => &[AwsConnection, Registry],
128        CreateConnectionType::Gcp => &[ServiceAccountKey],
129        CreateConnectionType::Csr => &[
130            AwsPrivatelink,
131            Password,
132            Port,
133            SshTunnel,
134            SslCertificate,
135            SslCertificateAuthority,
136            SslKey,
137            Url,
138            User,
139        ],
140        CreateConnectionType::Kafka => &[
141            AwsConnection,
142            Broker,
143            Brokers,
144            ProgressTopic,
145            ProgressTopicReplicationFactor,
146            AwsPrivatelink,
147            SshTunnel,
148            SslKey,
149            SslCertificate,
150            SslCertificateAuthority,
151            SaslMechanisms,
152            SaslUsername,
153            SaslPassword,
154            SecurityProtocol,
155        ],
156        CreateConnectionType::Postgres => &[
157            AwsPrivatelink,
158            Database,
159            Host,
160            Password,
161            Port,
162            SshTunnel,
163            SslCertificate,
164            SslCertificateAuthority,
165            SslKey,
166            SslMode,
167            User,
168        ],
169        CreateConnectionType::Ssh => &[Host, Port, User, PublicKey1, PublicKey2],
170        CreateConnectionType::MySql => &[
171            AwsPrivatelink,
172            Host,
173            Password,
174            Port,
175            SshTunnel,
176            SslCertificate,
177            SslCertificateAuthority,
178            SslKey,
179            SslMode,
180            User,
181            AwsConnection,
182        ],
183        CreateConnectionType::SqlServer => &[
184            AwsPrivatelink,
185            Database,
186            Host,
187            Password,
188            Port,
189            SshTunnel,
190            SslCertificate,
191            SslCertificateAuthority,
192            SslKey,
193            SslMode,
194            User,
195        ],
196        CreateConnectionType::IcebergCatalog => &[
197            AccessDelegation,
198            AwsConnection,
199            CatalogType,
200            Credential,
201            GcpConnection,
202            Oauth2ServerUrl,
203            Scope,
204            StorageProvider,
205            Url,
206            Warehouse,
207        ],
208    };
209
210    for o in permitted_options {
211        options.remove(o);
212    }
213
214    if !options.is_empty() {
215        sql_bail!(
216            "{} connections do not support {} values",
217            t,
218            options.iter().join(", ")
219        )
220    }
221
222    Ok(())
223}
224
225impl ConnectionOptionExtracted {
226    pub(super) fn ensure_only_valid_options(
227        &self,
228        t: CreateConnectionType,
229    ) -> Result<(), PlanError> {
230        validate_options_per_connection_type(t, self.seen.clone())
231    }
232
233    pub fn try_into_connection_details(
234        self,
235        scx: &StatementContext,
236        connection_type: CreateConnectionType,
237    ) -> Result<ConnectionDetails, PlanError> {
238        self.ensure_only_valid_options(connection_type)?;
239
240        let connection: ConnectionDetails = match connection_type {
241            CreateConnectionType::Aws => {
242                let credentials = match (
243                    self.access_key_id,
244                    self.secret_access_key,
245                    self.session_token,
246                ) {
247                    (Some(access_key_id), Some(secret_access_key), session_token) => {
248                        Some(AwsCredentials {
249                            access_key_id,
250                            secret_access_key: secret_access_key.into(),
251                            session_token,
252                        })
253                    }
254                    (None, None, None) => None,
255                    _ => {
256                        sql_bail!(
257                            "must specify both ACCESS KEY ID and SECRET ACCESS KEY with optional SESSION TOKEN"
258                        );
259                    }
260                };
261
262                let assume_role = match (self.assume_role_arn, self.assume_role_session_name) {
263                    (Some(arn), session_name) => Some(AwsAssumeRole { arn, session_name }),
264                    (None, Some(_)) => {
265                        sql_bail!(
266                            "must specify ASSUME ROLE ARN with optional ASSUME ROLE SESSION NAME"
267                        );
268                    }
269                    _ => None,
270                };
271
272                let auth = match (credentials, assume_role) {
273                    (None, None) => sql_bail!(
274                        "must specify either ASSUME ROLE ARN or ACCESS KEY ID and SECRET ACCESS KEY"
275                    ),
276                    (Some(credentials), None) => AwsAuth::Credentials(credentials),
277                    (None, Some(assume_role)) => AwsAuth::AssumeRole(assume_role),
278                    (Some(_), Some(_)) => {
279                        sql_bail!("cannot specify both ACCESS KEY ID and ASSUME ROLE ARN");
280                    }
281                };
282
283                ConnectionDetails::Aws(AwsConnection {
284                    auth,
285                    endpoint: match self.endpoint {
286                        // TODO(benesch): this should not treat an empty endpoint as equivalent to a `NULL`
287                        // endpoint, but making that change now would break testdrive. AWS connections are
288                        // all behind feature flags mode right now, so no particular urgency to correct
289                        // this.
290                        Some(endpoint) if !endpoint.is_empty() => Some(endpoint),
291                        _ => None,
292                    },
293                    region: self.region,
294                })
295            }
296            CreateConnectionType::AwsPrivatelink => {
297                let connection = AwsPrivatelinkConnection {
298                    service_name: self
299                        .service_name
300                        .ok_or_else(|| sql_err!("SERVICE NAME option is required"))?,
301                    availability_zones: self
302                        .availability_zones
303                        .ok_or_else(|| sql_err!("AVAILABILITY ZONES option is required"))?,
304                };
305                if let Some(supported_azs) = scx.catalog.aws_privatelink_availability_zones() {
306                    let mut unique_azs: BTreeSet<String> = BTreeSet::new();
307                    let mut duplicate_azs: BTreeSet<String> = BTreeSet::new();
308                    // Validate each AZ is supported
309                    for connection_az in &connection.availability_zones {
310                        if unique_azs.contains(connection_az) {
311                            duplicate_azs.insert(connection_az.to_string());
312                        } else {
313                            unique_azs.insert(connection_az.to_string());
314                        }
315                        if !supported_azs.contains(connection_az) {
316                            return Err(PlanError::InvalidPrivatelinkAvailabilityZone {
317                                name: connection_az.to_string(),
318                                supported_azs,
319                            });
320                        }
321                    }
322                    if duplicate_azs.len() > 0 {
323                        return Err(PlanError::DuplicatePrivatelinkAvailabilityZone {
324                            duplicate_azs,
325                        });
326                    }
327                }
328                ConnectionDetails::AwsPrivatelink(connection)
329            }
330            CreateConnectionType::Gcp => {
331                let credentials_json = self
332                    .service_account_key
333                    .ok_or_else(|| sql_err!("SERVICE ACCOUNT KEY option is required"))?
334                    .into();
335                ConnectionDetails::Gcp(GcpConnection { credentials_json })
336            }
337            CreateConnectionType::Kafka => {
338                let (tls, sasl) = plan_kafka_security(scx, &self)?;
339                let (static_brokers, matching_rules) = self.get_brokers_and_rules(scx)?;
340
341                if !matching_rules.is_empty() {
342                    scx.require_feature_flag(&vars::ENABLE_KAFKA_BROKER_MATCHING_RULES)?;
343                }
344
345                ConnectionDetails::Kafka(KafkaConnection {
346                    brokers: static_brokers,
347                    default_tunnel: build_tunnel_definition(
348                            scx,
349                            self.ssh_tunnel,
350                            self.aws_privatelink,
351                            if matching_rules.is_empty() { None } else { Some(matching_rules) },
352                        )?,
353                    progress_topic: self.progress_topic,
354                    progress_topic_options: KafkaTopicOptions {
355                        // We only allow configuring the progress topic replication factor for now.
356                        // For correctness, the partition count MUST be one and for performance the compaction
357                        // policy MUST be enabled.
358                        partition_count: Some(NonNeg::try_from(1).expect("1 is positive")),
359                        replication_factor: self.progress_topic_replication_factor.map(|val| {
360                            if val <= 0 {
361                                Err(sql_err!("invalid CONNECTION: PROGRESS TOPIC REPLICATION FACTOR must be greater than 0"))?
362                            }
363                            NonNeg::try_from(val).map_err(|e| sql_err!("{e}"))
364                        }).transpose()?,
365                        topic_config: btreemap! {
366                            "cleanup.policy".to_string() => "compact".to_string(),
367                            "segment.bytes".to_string() => "134217728".to_string(), // 128 MiB
368                        },
369                    },
370                    options: BTreeMap::new(),
371                    tls,
372                    sasl,
373                })
374            }
375            CreateConnectionType::Csr => {
376                let url: reqwest::Url = match self.url {
377                    Some(url) => url
378                        .parse()
379                        .map_err(|e| sql_err!("parsing schema registry url: {e}"))?,
380                    None => sql_bail!("invalid CONNECTION: must specify URL"),
381                };
382                let _ = url
383                    .host_str()
384                    .ok_or_else(|| sql_err!("invalid CONNECTION: URL must specify domain name"))?;
385                if url.path() != "/" {
386                    sql_bail!("invalid CONNECTION: URL must have an empty path");
387                }
388                let cert = self.ssl_certificate;
389                let key = self.ssl_key.map(|secret| secret.into());
390                let tls_identity = match (cert, key) {
391                    (None, None) => None,
392                    (Some(cert), Some(key)) => Some(TlsIdentity { cert, key }),
393                    _ => sql_bail!(
394                        "invalid CONNECTION: reading from SSL-auth Confluent Schema Registry requires both SSL KEY and SSL CERTIFICATE"
395                    ),
396                };
397                let http_auth = self.user.map(|username| CsrConnectionHttpAuth {
398                    username,
399                    password: self.password.map(|secret| secret.into()),
400                });
401
402                // TODO we should move to self.port being unsupported if aws_privatelink is some, see <https://github.com/MaterializeInc/database-issues/issues/7359#issuecomment-1925443977>
403                if let Some(privatelink) = self.aws_privatelink.as_ref() {
404                    if privatelink.port.is_some() {
405                        sql_bail!(
406                            "invalid CONNECTION: CONFLUENT SCHEMA REGISTRY does not support PORT for AWS PRIVATELINK"
407                        )
408                    }
409                }
410                let tunnel = build_tunnel_definition(
411                    scx,
412                    self.ssh_tunnel,
413                    self.aws_privatelink,
414                    None, /* Rule-based PrivateLink is not supported for CSR. */
415                )?;
416
417                ConnectionDetails::Csr(CsrConnection {
418                    url,
419                    tls_root_cert: self.ssl_certificate_authority,
420                    tls_identity,
421                    http_auth,
422                    tunnel,
423                })
424            }
425            CreateConnectionType::GlueSchemaRegistry => {
426                let aws_connection = get_aws_connection_reference(scx, &self)?
427                    .ok_or_else(|| sql_err!("AWS CONNECTION option is required"))?;
428                let registry_name = self
429                    .registry
430                    .ok_or_else(|| sql_err!("REGISTRY option is required"))?;
431                if registry_name.is_empty() {
432                    sql_bail!("invalid CONNECTION: REGISTRY must not be empty");
433                }
434
435                ConnectionDetails::GlueSchemaRegistry(GlueSchemaRegistryConnection {
436                    aws_connection,
437                    registry_name,
438                })
439            }
440            CreateConnectionType::Postgres => {
441                let cert = self.ssl_certificate;
442                let key = self.ssl_key.map(|secret| secret.into());
443                let tls_identity = match (cert, key) {
444                    (None, None) => None,
445                    (Some(cert), Some(key)) => Some(TlsIdentity { cert, key }),
446                    _ => sql_bail!(
447                        "invalid CONNECTION: both SSL KEY and SSL CERTIFICATE are required"
448                    ),
449                };
450                let tls_mode = match self.ssl_mode.as_ref().map(|m| m.as_str()) {
451                    None | Some("disable") => tokio_postgres::config::SslMode::Disable,
452                    // "prefer" intentionally omitted because it has dubious security
453                    // properties.
454                    Some("require") | Some("required") => tokio_postgres::config::SslMode::Require,
455                    Some("verify_ca") | Some("verify-ca") => {
456                        tokio_postgres::config::SslMode::VerifyCa
457                    }
458                    Some("verify_full") | Some("verify-full") => {
459                        tokio_postgres::config::SslMode::VerifyFull
460                    }
461                    Some(m) => sql_bail!("invalid CONNECTION: unknown SSL MODE {}", m.quoted()),
462                };
463
464                // TODO we should move to self.port being unsupported if aws_privatelink is some, see <https://github.com/MaterializeInc/database-issues/issues/7359#issuecomment-1925443977>
465                if let Some(privatelink) = self.aws_privatelink.as_ref() {
466                    if privatelink.port.is_some() {
467                        sql_bail!(
468                            "invalid CONNECTION: POSTGRES does not support PORT for AWS PRIVATELINK"
469                        )
470                    }
471                }
472                let tunnel = build_tunnel_definition(
473                    scx,
474                    self.ssh_tunnel,
475                    self.aws_privatelink,
476                    None, /* Rule-based PrivateLink is not supported for Postgres. */
477                )?;
478
479                ConnectionDetails::Postgres(PostgresConnection {
480                    database: self
481                        .database
482                        .ok_or_else(|| sql_err!("DATABASE option is required"))?,
483                    password: self.password.map(|password| password.into()),
484                    host: self
485                        .host
486                        .ok_or_else(|| sql_err!("HOST option is required"))?,
487                    port: self.port.unwrap_or(5432_u16),
488                    tunnel,
489                    tls_mode,
490                    tls_root_cert: self.ssl_certificate_authority,
491                    tls_identity,
492                    user: self
493                        .user
494                        .ok_or_else(|| sql_err!("USER option is required"))?,
495                })
496            }
497            CreateConnectionType::Ssh => {
498                let ensure_key = |public_key| match public_key {
499                    Some(public_key) => Ok::<_, anyhow::Error>(SshKey::PublicOnly(public_key)),
500                    None => {
501                        let key = SshKeyPair::new().context("creating SSH key")?;
502                        Ok(SshKey::Both(key))
503                    }
504                };
505                ConnectionDetails::Ssh {
506                    connection: SshConnection {
507                        host: self
508                            .host
509                            .ok_or_else(|| sql_err!("HOST option is required"))?,
510                        port: self.port.unwrap_or(22_u16),
511                        user: match self
512                            .user
513                            .ok_or_else(|| sql_err!("USER option is required"))?
514                        {
515                            StringOrSecret::String(user) => user,
516                            StringOrSecret::Secret(_) => {
517                                sql_bail!(
518                                    "SSH connections do not support supplying USER value as SECRET"
519                                )
520                            }
521                        },
522                    },
523                    key_1: ensure_key(self.public_key1)?,
524                    key_2: ensure_key(self.public_key2)?,
525                }
526            }
527            CreateConnectionType::MySql => {
528                let aws_connection = get_aws_connection_reference(scx, &self)?;
529                if aws_connection.is_some() && self.password.is_some() {
530                    sql_bail!(
531                        "invalid CONNECTION: AWS IAM authentication is not supported with password"
532                    );
533                }
534
535                let cert = self.ssl_certificate;
536                let key = self.ssl_key.map(|secret| secret.into());
537                let tls_identity = match (cert, key) {
538                    (None, None) => None,
539                    (Some(cert), Some(key)) => Some(TlsIdentity { cert, key }),
540                    _ => sql_bail!(
541                        "invalid CONNECTION: both SSL KEY and SSL CERTIFICATE are required"
542                    ),
543                };
544                // Accepts the same SSL Mode values as the MySQL Client
545                // https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-mode
546                let tls_mode = match self
547                    .ssl_mode
548                    .map(|f| f.to_uppercase())
549                    .as_ref()
550                    .map(|m| m.as_str())
551                {
552                    None | Some("DISABLED") => {
553                        if aws_connection.is_some() {
554                            sql_bail!(
555                                "invalid CONNECTION: AWS IAM authentication requires SSL to be enabled"
556                            )
557                        }
558                        MySqlSslMode::Disabled
559                    }
560                    // "preferred" intentionally omitted because it has dubious security
561                    // properties.
562                    Some("REQUIRED") | Some("REQUIRE") => MySqlSslMode::Required,
563                    Some("VERIFY_CA") | Some("VERIFY-CA") => MySqlSslMode::VerifyCa,
564                    Some("VERIFY_IDENTITY") | Some("VERIFY-IDENTITY") => {
565                        MySqlSslMode::VerifyIdentity
566                    }
567                    Some(m) => sql_bail!("invalid CONNECTION: unknown SSL MODE {}", m.quoted()),
568                };
569
570                // TODO we should move to self.port being unsupported if aws_privatelink is some, see <https://github.com/MaterializeInc/database-issues/issues/7359#issuecomment-1925443977>
571                if let Some(privatelink) = self.aws_privatelink.as_ref() {
572                    if privatelink.port.is_some() {
573                        sql_bail!(
574                            "invalid CONNECTION: MYSQL does not support PORT for AWS PRIVATELINK"
575                        )
576                    }
577                }
578                let tunnel = build_tunnel_definition(
579                    scx,
580                    self.ssh_tunnel,
581                    self.aws_privatelink,
582                    None, /* Rule-based PrivateLink is not supported for MySQL. */
583                )?;
584
585                ConnectionDetails::MySql(MySqlConnection {
586                    password: self.password.map(|password| password.into()),
587                    host: self
588                        .host
589                        .ok_or_else(|| sql_err!("HOST option is required"))?,
590                    port: self.port.unwrap_or(3306_u16),
591                    tunnel,
592                    tls_mode,
593                    tls_root_cert: self.ssl_certificate_authority,
594                    tls_identity,
595                    user: self
596                        .user
597                        .ok_or_else(|| sql_err!("USER option is required"))?,
598                    aws_connection,
599                })
600            }
601            CreateConnectionType::SqlServer => {
602                let aws_connection = get_aws_connection_reference(scx, &self)?;
603                if aws_connection.is_some() && self.password.is_some() {
604                    sql_bail!(
605                        "invalid CONNECTION: AWS IAM authentication is not supported with password"
606                    );
607                }
608
609                let (encryption, certificate_validation_policy) = match self
610                    .ssl_mode
611                    .map(|mode| mode.to_uppercase())
612                    .as_ref()
613                    .map(|mode| mode.as_str())
614                {
615                    None | Some("DISABLED") => (
616                        mz_sql_server_util::config::EncryptionLevel::None,
617                        mz_sql_server_util::config::CertificateValidationPolicy::TrustAll,
618                    ),
619                    Some("REQUIRED") => (
620                        mz_sql_server_util::config::EncryptionLevel::Required,
621                        mz_sql_server_util::config::CertificateValidationPolicy::TrustAll,
622                    ),
623                    Some("VERIFY") => (
624                        mz_sql_server_util::config::EncryptionLevel::Required,
625                        mz_sql_server_util::config::CertificateValidationPolicy::VerifySystem,
626                    ),
627                    Some("VERIFY_CA") => {
628                        if self.ssl_certificate_authority.is_none() {
629                            sql_bail!(
630                                "invalid CONNECTION: SSL MODE 'verify_ca' requires SSL CERTIFICATE AUTHORITY"
631                            );
632                        }
633                        (
634                            mz_sql_server_util::config::EncryptionLevel::Required,
635                            mz_sql_server_util::config::CertificateValidationPolicy::VerifyCA,
636                        )
637                    }
638                    Some(mode) => {
639                        sql_bail!("invalid CONNECTION: unknown SSL MODE {}", mode.quoted())
640                    }
641                };
642
643                if let Some(privatelink) = self.aws_privatelink.as_ref() {
644                    if privatelink.port.is_some() {
645                        sql_bail!(
646                            "invalid CONNECTION: SQL SERVER does not support PORT for AWS PRIVATELINK"
647                        )
648                    }
649                }
650
651                // 1433 is the default port for SQL Server instances running over TCP.
652                //
653                // See: <https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-a-server-to-listen-on-a-specific-tcp-port?view=sql-server-ver16>
654                let port = self.port.unwrap_or(1433_u16);
655                let tunnel = build_tunnel_definition(
656                    scx,
657                    self.ssh_tunnel,
658                    self.aws_privatelink,
659                    None, /* Rule-based PrivateLink is not supported for SQL Server. */
660                )?;
661
662                ConnectionDetails::SqlServer(SqlServerConnectionDetails {
663                    host: self
664                        .host
665                        .ok_or_else(|| sql_err!("HOST option is required"))?,
666                    port,
667                    database: self
668                        .database
669                        .ok_or_else(|| sql_err!("DATABASE option is required"))?,
670                    user: self
671                        .user
672                        .ok_or_else(|| sql_err!("USER option is required"))?,
673                    password: self
674                        .password
675                        .ok_or_else(|| sql_err!("PASSWORD option is required"))
676                        .map(|pass| pass.into())?,
677                    tunnel,
678                    encryption,
679                    certificate_validation_policy,
680                    tls_root_cert: self.ssl_certificate_authority,
681                })
682            }
683            CreateConnectionType::IcebergCatalog => {
684                let catalog_type = self.catalog_type.clone().ok_or_else(|| {
685                    sql_err!("invalid CONNECTION: ICEBERG connections must specify CATALOG TYPE")
686                })?;
687
688                let uri: reqwest::Url = match &self.url {
689                    Some(url) => url
690                        .parse()
691                        .map_err(|e| sql_err!("parsing Iceberg catalog url: {e}"))?,
692                    None => sql_bail!("invalid CONNECTION: must specify URL"),
693                };
694
695                let warehouse = self.warehouse.clone();
696                let credential = self.credential.clone();
697                let aws_connection = get_aws_connection_reference(scx, &self)?;
698                let gcp_connection = get_gcp_connection_reference(scx, &self)?;
699
700                let catalog = match catalog_type {
701                    IcebergCatalogType::S3TablesRest => {
702                        if gcp_connection.is_some() {
703                            sql_bail!(
704                                "invalid CONNECTION: ICEBERG s3tablesrest connections do not support GCP CONNECTION"
705                            );
706                        }
707                        if self.oauth2_server_url.is_some() {
708                            sql_bail!(
709                                "invalid CONNECTION: ICEBERG s3tablesrest connections do not support OAUTH2 SERVER URL"
710                            );
711                        }
712                        if self.access_delegation.is_some() {
713                            sql_bail!(
714                                "invalid CONNECTION: ICEBERG s3tablesrest connections do not support ACCESS DELEGATION"
715                            );
716                        }
717                        if self.storage_provider.is_some() {
718                            sql_bail!(
719                                "invalid CONNECTION: ICEBERG s3tablesrest connections do not support STORAGE PROVIDER"
720                            );
721                        }
722                        let Some(warehouse) = warehouse else {
723                            sql_bail!(
724                                "invalid CONNECTION: ICEBERG s3tablesrest connections must specify WAREHOUSE"
725                            );
726                        };
727                        let Some(aws_connection) = aws_connection else {
728                            sql_bail!(
729                                "invalid CONNECTION: ICEBERG s3tablesrest connections require an AWS connection"
730                            );
731                        };
732
733                        IcebergCatalogImpl::S3TablesRest(S3TablesRestIcebergCatalog {
734                            aws_connection,
735                            warehouse,
736                        })
737                    }
738                    IcebergCatalogType::Rest => {
739                        if aws_connection.is_some() {
740                            sql_bail!(
741                                "invalid CONNECTION: ICEBERG rest connections do not support AWS CONNECTION.\n\nTry s3tablesrest instead."
742                            );
743                        }
744                        let auth = match (credential, gcp_connection) {
745                            (Some(_), Some(_)) => sql_bail!(
746                                "invalid CONNECTION: ICEBERG rest connections may set CREDENTIAL or GCP CONNECTION, not both"
747                            ),
748                            (Some(credential), None) => IcebergCatalogAuth::OAuth {
749                                credential,
750                                scope: self.scope.clone(),
751                                server_url: self.oauth2_server_url.clone(),
752                            },
753                            (None, Some(gcp_connection)) => {
754                                if self.oauth2_server_url.is_some() {
755                                    sql_bail!(
756                                        "invalid CONNECTION: OAUTH2 SERVER URL applies to CREDENTIAL auth, not GCP CONNECTION"
757                                    );
758                                }
759                                // A GCP connection authenticates to GCS, so the
760                                // store is already determined. Reject a
761                                // contradicting value rather than silently
762                                // overriding it.
763                                if let Some(provider) = self.storage_provider
764                                    && provider != IcebergStorageProvider::Gcs
765                                {
766                                    sql_bail!(
767                                        "invalid CONNECTION: ICEBERG GCP CONNECTION implies STORAGE PROVIDER 'gcs', not '{}'",
768                                        provider.as_str()
769                                    );
770                                }
771                                /// All BigLake Iceberg REST Catalogs use the same catalog URI.
772                                const BIGLAKE_CATALOG_URI: &str =
773                                    "https://biglake.googleapis.com/iceberg/v1/restcatalog";
774                                if uri.to_string() != BIGLAKE_CATALOG_URI {
775                                    sql_bail!(
776                                        "GCP connection can only be used with '{}'",
777                                        BIGLAKE_CATALOG_URI
778                                    );
779                                }
780                                IcebergCatalogAuth::Gcp(gcp_connection)
781                            }
782                            (None, None) => sql_bail!(
783                                "invalid CONNECTION: ICEBERG rest connections require a CREDENTIAL or GCP CONNECTION"
784                            ),
785                        };
786
787                        // A GCP connection only authenticates to GCS, so it
788                        // fixes the store regardless of what was written.
789                        let storage_provider = if matches!(auth, IcebergCatalogAuth::Gcp(_)) {
790                            IcebergStorageProvider::Gcs
791                        } else {
792                            self.storage_provider.unwrap_or_default()
793                        };
794
795                        IcebergCatalogImpl::Rest(RestIcebergCatalog {
796                            auth,
797                            warehouse,
798                            access_delegation: self.access_delegation,
799                            storage_provider,
800                        })
801                    }
802                };
803
804                ConnectionDetails::IcebergCatalog(IcebergCatalogConnection { catalog, uri })
805            }
806        };
807
808        Ok(connection)
809    }
810
811    pub fn get_brokers_and_rules(
812        &self,
813        scx: &StatementContext,
814    ) -> Result<
815        (
816            Vec<mz_storage_types::connections::KafkaBroker<ReferencedConnection>>,
817            Vec<KafkaMatchingBrokerRule<Aug>>,
818        ),
819        PlanError,
820    > {
821        // Collect all static broker sources and matching rules.
822        let mut all_brokers: Vec<KafkaBroker<Aug>> = vec![];
823        let mut matching_rules: Vec<KafkaMatchingBrokerRule<Aug>> = vec![];
824
825        // Check exclusions and extract brokers
826        match (&self.broker, &self.brokers, &self.aws_privatelink) {
827            // BROKER
828            (Some(broker), None, None) => all_brokers.extend(broker.iter().cloned()),
829            // BROKERS
830            (None, Some(broker_list), None) => {
831                all_brokers.extend(broker_list.static_entries.iter().cloned());
832                matching_rules.extend(broker_list.matching_rules.iter().cloned());
833            }
834            // AWS PRIVATELINK
835            (None, None, Some(_privatelink)) => {
836                // Noting to do here
837            }
838            // noting - invalid
839            (None, None, None) => {
840                sql_bail!("invalid CONNECTION: must set one of BROKER, BROKERS, or AWS PRIVATELINK")
841            }
842            // exclusive keywords provided
843            _ => sql_bail!(
844                "invalid CONNECTION: can only set one of BROKER, BROKERS, or AWS PRIVATELINK"
845            ),
846        };
847
848        // MATCHING rules require at least one static broker for bootstrapping.
849        if !matching_rules.is_empty() && all_brokers.is_empty() {
850            sql_bail!(
851                "invalid CONNECTION: BROKERS must contain at least one static broker address"
852            );
853        }
854
855        // NOTE: we allow broker configurations to be mixed and matched. If/when we support
856        // a top-level `SSH TUNNEL` configuration, we will need additional assertions.
857        let mut out = vec![];
858        for broker in &all_brokers {
859            if broker.address.contains(',') {
860                sql_bail!(
861                    "invalid CONNECTION: cannot specify multiple Kafka broker addresses in one string.\n\nInstead, specify BROKERS using multiple strings, e.g. BROKERS ('kafka:9092', 'kafka:9093')"
862                );
863            }
864
865            let tunnel = match &broker.tunnel {
866                KafkaBrokerTunnel::Direct => Tunnel::Direct,
867                KafkaBrokerTunnel::AwsPrivatelink(aws_privatelink) => {
868                    Tunnel::AwsPrivatelink(plan_privatelink(scx, aws_privatelink)?)
869                }
870                KafkaBrokerTunnel::SshTunnel(ssh) => {
871                    let id = match &ssh {
872                        ResolvedItemName::Item { id, .. } => id,
873                        _ => sql_bail!(
874                            "internal error: Kafka SSH tunnel connection was not resolved"
875                        ),
876                    };
877                    let ssh_tunnel = scx.catalog.get_item(id);
878                    match ssh_tunnel.connection()? {
879                        Connection::Ssh(_connection) => Tunnel::Ssh(SshTunnel {
880                            connection_id: *id,
881                            connection: *id,
882                        }),
883                        _ => {
884                            sql_bail!("{} is not an SSH connection", ssh_tunnel.name().item)
885                        }
886                    }
887                }
888            };
889
890            out.push(mz_storage_types::connections::KafkaBroker {
891                address: broker.address.clone(),
892                tunnel,
893            });
894        }
895
896        Ok((out, matching_rules))
897    }
898}
899
900fn get_aws_connection_reference(
901    scx: &StatementContext,
902    conn_options: &ConnectionOptionExtracted,
903) -> Result<Option<AwsConnectionReference<ReferencedConnection>>, PlanError> {
904    let Some(aws_connection_id) = conn_options.aws_connection else {
905        return Ok(None);
906    };
907
908    let id = CatalogItemId::from(aws_connection_id);
909    let item = scx.catalog.get_item(&id);
910    Ok(match item.connection()? {
911        Connection::Aws(_) => Some(AwsConnectionReference {
912            connection_id: id,
913            connection: id,
914        }),
915        _ => sql_bail!("{} is not an AWS connection", item.name().item),
916    })
917}
918fn get_gcp_connection_reference(
919    scx: &StatementContext,
920    conn_options: &ConnectionOptionExtracted,
921) -> Result<Option<GcpConnectionReference<ReferencedConnection>>, PlanError> {
922    let Some(gcp_connection_id) = conn_options.gcp_connection else {
923        return Ok(None);
924    };
925
926    let id = CatalogItemId::from(gcp_connection_id);
927    let item = scx.catalog.get_item(&id);
928    Ok(match item.connection()? {
929        Connection::Gcp(_) => Some(GcpConnectionReference {
930            connection_id: id,
931            connection: id,
932        }),
933        _ => sql_bail!("{} is not a GCP connection", item.name().item),
934    })
935}
936
937fn plan_kafka_security(
938    scx: &StatementContext,
939    v: &ConnectionOptionExtracted,
940) -> Result<
941    (
942        Option<KafkaTlsConfig>,
943        Option<KafkaSaslConfig<ReferencedConnection>>,
944    ),
945    PlanError,
946> {
947    const SASL_CONFIGS: [ConnectionOptionName; 4] = [
948        ConnectionOptionName::AwsConnection,
949        ConnectionOptionName::SaslMechanisms,
950        ConnectionOptionName::SaslUsername,
951        ConnectionOptionName::SaslPassword,
952    ];
953
954    const ALL_CONFIGS: [ConnectionOptionName; 7] = concat_arrays!(
955        [
956            ConnectionOptionName::SslKey,
957            ConnectionOptionName::SslCertificate,
958            ConnectionOptionName::SslCertificateAuthority,
959        ],
960        SASL_CONFIGS
961    );
962
963    enum SecurityProtocol {
964        Plaintext,
965        Ssl,
966        SaslPlaintext,
967        SaslSsl,
968    }
969
970    let security_protocol = v.security_protocol.as_ref().map(|v| v.to_uppercase());
971    let security_protocol = match security_protocol.as_deref() {
972        Some("PLAINTEXT") => SecurityProtocol::Plaintext,
973        Some("SSL") => SecurityProtocol::Ssl,
974        Some("SASL_PLAINTEXT") => SecurityProtocol::SaslPlaintext,
975        Some("SASL_SSL") => SecurityProtocol::SaslSsl,
976        Some(p) => sql_bail!("unknown security protocol: {}", p),
977        // To be secure by default, if no security protocol is explicitly
978        // specified, we always choose one of the SSL-enabled protocols, using
979        // the presence of any SASL options to guide us between them. Users must
980        // explicitly choose a plaintext mechanism if that's what they want.
981        None if SASL_CONFIGS.iter().any(|c| v.seen.contains(c)) => SecurityProtocol::SaslSsl,
982        None => SecurityProtocol::Ssl,
983    };
984
985    let mut outstanding = ALL_CONFIGS
986        .into_iter()
987        .filter(|c| v.seen.contains(c))
988        .collect::<BTreeSet<ConnectionOptionName>>();
989
990    let tls = match security_protocol {
991        SecurityProtocol::Ssl | SecurityProtocol::SaslSsl => {
992            outstanding.remove(&ConnectionOptionName::SslCertificate);
993            let identity = match &v.ssl_certificate {
994                None => None,
995                Some(cert) => {
996                    outstanding.remove(&ConnectionOptionName::SslKey);
997                    let Some(key) = &v.ssl_key else {
998                        sql_bail!("SSL KEY must be specified with SSL CERTIFICATE");
999                    };
1000                    Some(TlsIdentity {
1001                        cert: cert.clone(),
1002                        key: (*key).into(),
1003                    })
1004                }
1005            };
1006            outstanding.remove(&ConnectionOptionName::SslCertificateAuthority);
1007            Some(KafkaTlsConfig {
1008                identity,
1009                root_cert: v.ssl_certificate_authority.clone(),
1010            })
1011        }
1012        _ => None,
1013    };
1014
1015    let sasl = match security_protocol {
1016        SecurityProtocol::SaslPlaintext | SecurityProtocol::SaslSsl => {
1017            outstanding.remove(&ConnectionOptionName::AwsConnection);
1018            match get_aws_connection_reference(scx, v)? {
1019                Some(aws) => Some(KafkaSaslConfig {
1020                    mechanism: "OAUTHBEARER".into(),
1021                    username: "".into(),
1022                    password: None,
1023                    aws: Some(aws),
1024                }),
1025                None => {
1026                    outstanding.remove(&ConnectionOptionName::SaslMechanisms);
1027                    outstanding.remove(&ConnectionOptionName::SaslUsername);
1028                    outstanding.remove(&ConnectionOptionName::SaslPassword);
1029                    // TODO(benesch): support a less confusing `SASL MECHANISM`
1030                    // alias, as only a single mechanism that can be specified.
1031                    let Some(mechanism) = &v.sasl_mechanisms else {
1032                        sql_bail!("SASL MECHANISMS must be specified");
1033                    };
1034                    let Some(username) = &v.sasl_username else {
1035                        sql_bail!("SASL USERNAME must be specified");
1036                    };
1037                    let Some(password) = &v.sasl_password else {
1038                        sql_bail!("SASL PASSWORD must be specified");
1039                    };
1040                    Some(KafkaSaslConfig {
1041                        // librdkafka requires SASL mechanisms to be upper case (PLAIN,
1042                        // SCRAM-SHA-256). For usability, we automatically uppercase the
1043                        // mechanism that user provides. This avoids a frustrating
1044                        // interaction with identifier case folding. Consider `SASL
1045                        // MECHANISMS = PLAIN`. Identifier case folding results in a
1046                        // SASL mechanism of `plain` (note the lowercase), which
1047                        // Materialize previously rejected with an error of "SASL
1048                        // mechanism must be uppercase." This was deeply frustarting for
1049                        // users who were not familiar with identifier case folding
1050                        // rules. See database-issues#6693.
1051                        mechanism: mechanism.to_uppercase(),
1052                        username: username.clone(),
1053                        password: Some((*password).into()),
1054                        aws: None,
1055                    })
1056                }
1057            }
1058        }
1059        _ => None,
1060    };
1061
1062    if let Some(outstanding) = outstanding.first() {
1063        sql_bail!("option {outstanding} not supported with this configuration");
1064    }
1065
1066    Ok((tls, sasl))
1067}
1068pub fn plan_default_privatelink(
1069    scx: &StatementContext,
1070    pl: &mz_sql_parser::ast::ConnectionDefaultAwsPrivatelink<Aug>,
1071) -> Result<AwsPrivatelink, PlanError> {
1072    let id = pl.connection.item_id().clone();
1073    let entry = scx.catalog.get_item(&id);
1074    match entry.connection()? {
1075        Connection::AwsPrivatelink(_) => Ok(AwsPrivatelink {
1076            connection_id: id,
1077            // By default we do not specify an availability zone for the tunnel.
1078            availability_zone: None,
1079            // We always use the port as specified by the top-level connection.
1080            port: pl.port,
1081        }),
1082        _ => sql_bail!("{} is not an AWS PRIVATELINK connection", entry.name().item),
1083    }
1084}
1085
1086pub fn plan_privatelink(
1087    scx: &StatementContext,
1088    pl: &mz_sql_parser::ast::KafkaBrokerAwsPrivatelink<Aug>,
1089) -> Result<AwsPrivatelink, PlanError> {
1090    let KafkaBrokerAwsPrivatelinkOptionExtracted {
1091        availability_zone,
1092        port,
1093        seen: _,
1094    } = KafkaBrokerAwsPrivatelinkOptionExtracted::try_from(pl.options.clone())?;
1095
1096    let id = match &pl.connection {
1097        ResolvedItemName::Item { id, .. } => id,
1098        _ => sql_bail!("internal error: Kafka PrivateLink connection was not resolved"),
1099    };
1100    let entry = scx.catalog.get_item(id);
1101    match entry.connection()? {
1102        Connection::AwsPrivatelink(connection) => {
1103            if let Some(az) = &availability_zone {
1104                if !connection.availability_zones.contains(az) {
1105                    sql_bail!(
1106                        "AWS PrivateLink availability zone {} does not match any of the \
1107                                      availability zones on the AWS PrivateLink connection {}",
1108                        az.quoted(),
1109                        scx.catalog
1110                            .resolve_full_name(entry.name())
1111                            .to_string()
1112                            .quoted()
1113                    )
1114                }
1115            }
1116            Ok(AwsPrivatelink {
1117                connection_id: *id,
1118                availability_zone,
1119                port,
1120            })
1121        }
1122        _ => {
1123            sql_bail!("{} is not an AWS PRIVATELINK connection", entry.name().item)
1124        }
1125    }
1126}
1127
1128pub(crate) fn build_tunnel_definition(
1129    scx: &StatementContext,
1130    ssh_tunnel: Option<with_options::Object>,
1131    aws_privatelink: Option<ConnectionDefaultAwsPrivatelink<Aug>>,
1132    matching_rules: Option<Vec<KafkaMatchingBrokerRule<Aug>>>,
1133) -> Result<Tunnel<ReferencedConnection>, PlanError> {
1134    Ok(match (ssh_tunnel, aws_privatelink, matching_rules) {
1135        (None, None, None) => Tunnel::Direct,
1136        (Some(ssh_tunnel), None, None) => {
1137            let id = CatalogItemId::from(ssh_tunnel);
1138            let ssh_tunnel = scx.catalog.get_item(&id);
1139            match ssh_tunnel.connection()? {
1140                Connection::Ssh(_connection) => Tunnel::Ssh(SshTunnel {
1141                    connection_id: id,
1142                    connection: id,
1143                }),
1144                _ => sql_bail!("{} is not an SSH connection", ssh_tunnel.name().item),
1145            }
1146        }
1147        (None, Some(aws_privatelink), None) => {
1148            Tunnel::AwsPrivatelink(plan_default_privatelink(scx, &aws_privatelink)?)
1149        }
1150        (None, None, Some(rules)) => {
1151            if rules.is_empty() {
1152                sql_bail!("BROKERS MATCHING rules list cannot be empty");
1153            }
1154
1155            let rules = rules
1156                .iter()
1157                .map(|rule| {
1158                    Ok(AwsPrivatelinkRule {
1159                        pattern: rule.pattern.clone(),
1160                        to: plan_privatelink(scx, &rule.tunnel)?,
1161                    })
1162                })
1163                .collect::<Result<Vec<_>, PlanError>>()?;
1164            Tunnel::AwsPrivatelinks(mz_storage_types::connections::AwsPrivatelinks { rules })
1165        }
1166        _ => {
1167            sql_bail!("cannot specify both SSH TUNNEL and AWS PRIVATELINK");
1168        }
1169    })
1170}