1use 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
103pub(crate) const INALTERABLE_OPTIONS: &[ConnectionOptionName] =
105 &[ProgressTopic, ProgressTopicReplicationFactor];
106
107pub(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 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 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 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(), },
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 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, )?;
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 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 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, )?;
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 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 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 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, )?;
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 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, )?;
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 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 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 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 let mut all_brokers: Vec<KafkaBroker<Aug>> = vec![];
823 let mut matching_rules: Vec<KafkaMatchingBrokerRule<Aug>> = vec![];
824
825 match (&self.broker, &self.brokers, &self.aws_privatelink) {
827 (Some(broker), None, None) => all_brokers.extend(broker.iter().cloned()),
829 (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 (None, None, Some(_privatelink)) => {
836 }
838 (None, None, None) => {
840 sql_bail!("invalid CONNECTION: must set one of BROKER, BROKERS, or AWS PRIVATELINK")
841 }
842 _ => sql_bail!(
844 "invalid CONNECTION: can only set one of BROKER, BROKERS, or AWS PRIVATELINK"
845 ),
846 };
847
848 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 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 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 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 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 availability_zone: None,
1079 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}