use super::KafkaConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthKind {
UserPassword,
Iam,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct ProviderCapabilities {
pub managed: bool,
pub always_on: bool,
pub has_billable_side_resources: bool,
pub serverless: bool,
pub requires_tls: bool,
pub auth_kind: AuthKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataMode {
KRaft,
Zookeeper,
Managed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaRegistry {
None,
Confluent,
Redpanda,
}
pub trait KafkaProvider {
fn name(&self) -> &str;
fn auth(&self) -> (&'static str, &'static str);
fn capabilities(&self) -> ProviderCapabilities;
fn transport_overrides(&self) -> &'static [(&'static str, &'static str)] {
&[]
}
fn metadata_mode(&self) -> MetadataMode {
MetadataMode::KRaft
}
fn schema_registry(&self) -> SchemaRegistry {
SchemaRegistry::None
}
fn apply_auth(&self, config: &mut KafkaConfig) {
let (proto, mech) = self.auth();
config.security_protocol = proto.to_ascii_lowercase();
config.sasl_mechanism = (!mech.is_empty()).then(|| mech.to_string());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KnownProvider {
Strimzi,
Redpanda,
Msk,
RedpandaCloud,
ConfluentCloud,
Plaintext,
MskIam,
}
impl KnownProvider {
pub fn parse(s: &str) -> Result<Self, String> {
Ok(match s {
"strimzi" => Self::Strimzi,
"redpanda" => Self::Redpanda,
"msk" => Self::Msk,
"redpanda-cloud" => Self::RedpandaCloud,
"confluent-cloud" => Self::ConfluentCloud,
"plaintext" => Self::Plaintext,
"msk_iam" => Self::MskIam,
other => {
return Err(format!(
"unknown kafka provider {other:?}; expected one of: strimzi, redpanda, \
msk, redpanda-cloud, confluent-cloud, plaintext, msk_iam"
));
}
})
}
}
impl KafkaProvider for KnownProvider {
fn name(&self) -> &str {
match self {
Self::Strimzi => "strimzi",
Self::Redpanda => "redpanda",
Self::Msk => "msk",
Self::RedpandaCloud => "redpanda-cloud",
Self::ConfluentCloud => "confluent-cloud",
Self::Plaintext => "plaintext",
Self::MskIam => "msk_iam",
}
}
fn auth(&self) -> (&'static str, &'static str) {
match self {
Self::Strimzi | Self::Redpanda | Self::Msk | Self::RedpandaCloud => {
("SASL_SSL", "SCRAM-SHA-512")
}
Self::ConfluentCloud => ("SASL_SSL", "PLAIN"),
Self::Plaintext => ("PLAINTEXT", ""),
Self::MskIam => ("SASL_SSL", "OAUTHBEARER"),
}
}
fn capabilities(&self) -> ProviderCapabilities {
let self_hosted = ProviderCapabilities {
managed: false,
always_on: false,
has_billable_side_resources: false,
serverless: false,
requires_tls: false,
auth_kind: AuthKind::UserPassword,
};
let managed = ProviderCapabilities {
managed: true,
always_on: true,
has_billable_side_resources: false,
serverless: true,
requires_tls: true,
auth_kind: AuthKind::UserPassword,
};
match self {
Self::Strimzi | Self::Redpanda => self_hosted,
Self::Msk => ProviderCapabilities {
serverless: false,
..managed
},
Self::RedpandaCloud => managed,
Self::ConfluentCloud => ProviderCapabilities {
has_billable_side_resources: true,
..managed
},
Self::Plaintext => ProviderCapabilities {
requires_tls: false,
auth_kind: AuthKind::None,
..self_hosted
},
Self::MskIam => ProviderCapabilities {
auth_kind: AuthKind::Iam,
..managed
},
}
}
fn metadata_mode(&self) -> MetadataMode {
match self {
Self::Redpanda | Self::Strimzi | Self::Plaintext | Self::Msk => MetadataMode::KRaft,
Self::RedpandaCloud | Self::ConfluentCloud | Self::MskIam => MetadataMode::Managed,
}
}
fn schema_registry(&self) -> SchemaRegistry {
match self {
Self::ConfluentCloud => SchemaRegistry::Confluent,
Self::Redpanda | Self::RedpandaCloud => SchemaRegistry::Redpanda,
Self::Strimzi | Self::Msk | Self::Plaintext | Self::MskIam => SchemaRegistry::None,
}
}
}
pub fn validate(security_protocol: &str, sasl_mechanism: &str) -> Result<(), String> {
if sasl_mechanism == "PLAIN" && !security_protocol.eq_ignore_ascii_case("SASL_SSL") {
return Err("PLAIN credentials require security_protocol=SASL_SSL \
(never send PLAIN over a plaintext transport)"
.to_string());
}
if security_protocol.eq_ignore_ascii_case("SASL_SSL") && sasl_mechanism.is_empty() {
return Err("security_protocol=SASL_SSL requires a sasl.mechanism".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const CANONICAL_TABLE: &[(&str, &str, &str)] = &[
("strimzi", "SASL_SSL", "SCRAM-SHA-512"),
("redpanda", "SASL_SSL", "SCRAM-SHA-512"),
("msk", "SASL_SSL", "SCRAM-SHA-512"),
("redpanda-cloud", "SASL_SSL", "SCRAM-SHA-512"),
("confluent-cloud", "SASL_SSL", "PLAIN"),
("plaintext", "PLAINTEXT", ""),
("msk_iam", "SASL_SSL", "OAUTHBEARER"),
];
#[test]
fn auth_matches_canonical_table() {
for (key, proto, mech) in CANONICAL_TABLE {
let provider = KnownProvider::parse(key).expect("known provider");
assert_eq!(provider.auth(), (*proto, *mech), "provider {key}");
assert_eq!(provider.name(), *key);
}
}
#[test]
fn confluent_is_the_only_plain() {
let plain: Vec<_> = CANONICAL_TABLE
.iter()
.filter(|(_, _, mech)| *mech == "PLAIN")
.map(|(k, _, _)| *k)
.collect();
assert_eq!(plain, vec!["confluent-cloud"]);
}
#[test]
fn unknown_provider_is_rejected() {
assert!(KnownProvider::parse("kinesis").is_err());
}
#[test]
fn validate_refuses_plain_over_plaintext() {
assert!(validate("PLAINTEXT", "PLAIN").is_err());
assert!(validate("sasl_plaintext", "PLAIN").is_err());
assert!(validate("SASL_SSL", "PLAIN").is_ok());
}
#[test]
fn validate_requires_mechanism_for_sasl_ssl() {
assert!(validate("SASL_SSL", "").is_err());
assert!(validate("SASL_SSL", "SCRAM-SHA-512").is_ok());
}
#[test]
fn every_derived_pair_passes_validation() {
for (key, _, _) in CANONICAL_TABLE {
let (proto, mech) = KnownProvider::parse(key).unwrap().auth();
validate(proto, mech).expect("derived pair must validate");
}
}
#[test]
fn apply_auth_sets_lowercase_protocol_and_mechanism() {
let mut cfg = KafkaConfig::default();
KnownProvider::ConfluentCloud.apply_auth(&mut cfg);
assert_eq!(cfg.security_protocol, "sasl_ssl");
assert_eq!(cfg.sasl_mechanism.as_deref(), Some("PLAIN"));
assert!(
cfg.validate(true).is_ok(),
"applied config passes the floor"
);
let mut dev = KafkaConfig::default();
KnownProvider::Plaintext.apply_auth(&mut dev);
assert_eq!(dev.security_protocol, "plaintext");
assert_eq!(dev.sasl_mechanism, None);
}
#[test]
fn managed_providers_are_always_on() {
for key in ["msk", "confluent-cloud", "redpanda-cloud", "msk_iam"] {
let caps = KnownProvider::parse(key).unwrap().capabilities();
assert!(caps.managed, "{key} is managed");
assert!(caps.always_on, "{key} bills continuously");
assert!(caps.requires_tls, "{key} mandates TLS");
}
}
#[test]
fn self_hosted_is_not_managed() {
for key in ["strimzi", "redpanda", "plaintext"] {
let caps = KnownProvider::parse(key).unwrap().capabilities();
assert!(!caps.managed, "{key} is self-hosted");
assert!(!caps.always_on);
}
}
#[test]
fn confluent_has_billable_side_resources() {
assert!(
KnownProvider::ConfluentCloud
.capabilities()
.has_billable_side_resources
);
assert!(
!KnownProvider::RedpandaCloud
.capabilities()
.has_billable_side_resources
);
}
#[test]
fn auth_kind_reflects_credential_family() {
assert_eq!(
KnownProvider::MskIam.capabilities().auth_kind,
AuthKind::Iam
);
assert_eq!(
KnownProvider::Strimzi.capabilities().auth_kind,
AuthKind::UserPassword
);
assert_eq!(
KnownProvider::Plaintext.capabilities().auth_kind,
AuthKind::None
);
}
#[test]
fn metadata_mode_reflects_deployment_shape() {
assert_eq!(KnownProvider::Redpanda.metadata_mode(), MetadataMode::KRaft);
assert_eq!(KnownProvider::Strimzi.metadata_mode(), MetadataMode::KRaft);
for key in ["confluent-cloud", "redpanda-cloud", "msk_iam"] {
assert_eq!(
KnownProvider::parse(key).unwrap().metadata_mode(),
MetadataMode::Managed,
"{key} is managed-hidden"
);
}
}
#[test]
fn schema_registry_kind_per_provider() {
assert_eq!(
KnownProvider::ConfluentCloud.schema_registry(),
SchemaRegistry::Confluent
);
assert_eq!(
KnownProvider::RedpandaCloud.schema_registry(),
SchemaRegistry::Redpanda
);
assert_eq!(
KnownProvider::Redpanda.schema_registry(),
SchemaRegistry::Redpanda
);
assert_eq!(
KnownProvider::Strimzi.schema_registry(),
SchemaRegistry::None
);
assert_eq!(KnownProvider::Msk.schema_registry(), SchemaRegistry::None);
}
struct DemoProvider;
impl KafkaProvider for DemoProvider {
#[allow(clippy::unnecessary_literal_bound)]
fn name(&self) -> &str {
"demo"
}
fn auth(&self) -> (&'static str, &'static str) {
("SASL_SSL", "SCRAM-SHA-256")
}
fn capabilities(&self) -> ProviderCapabilities {
KnownProvider::Redpanda.capabilities()
}
}
#[test]
fn third_party_provider_implements_the_trait() {
let mut cfg = KafkaConfig::default();
DemoProvider.apply_auth(&mut cfg);
assert_eq!(cfg.security_protocol, "sasl_ssl");
assert_eq!(cfg.sasl_mechanism.as_deref(), Some("SCRAM-SHA-256"));
let (proto, mech) = DemoProvider.auth();
assert!(validate(proto, mech).is_ok());
}
}