Skip to main content

krabka_security/
mechanism.rs

1use serde::{Deserialize, Serialize};
2
3use crate::AuthError;
4
5#[derive(
6    Debug,
7    Clone,
8    Copy,
9    PartialEq,
10    Eq,
11    Hash,
12    Serialize,
13    Deserialize,
14    strum::IntoStaticStr,
15    strum::EnumString,
16)]
17pub enum SaslMechanism {
18    #[strum(serialize = "PLAIN")]
19    Plain,
20    #[strum(serialize = "SCRAM-SHA-256")]
21    ScramSha256,
22    #[strum(serialize = "SCRAM-SHA-512")]
23    ScramSha512,
24    /// SASL/OAUTHBEARER (KIP-255 / RFC 7628). The broker's configured token
25    /// validator validates the bearer token.
26    #[strum(serialize = "OAUTHBEARER")]
27    OAuthBearer,
28    /// SASL/GSSAPI (Kerberos, RFC 4752). The GSS provider drives context
29    /// establishment and the RFC 4752 security-layer negotiation from the
30    /// broker's configured service keytab.
31    ///
32    /// The variant exists in every build, because metadata records persist it.
33    /// The provider needs the `gssapi` Cargo feature; see
34    /// [`SaslMechanism::is_available`].
35    #[strum(serialize = "GSSAPI")]
36    Gssapi,
37}
38
39impl SaslMechanism {
40    #[must_use]
41    pub fn wire_name(self) -> &'static str {
42        self.into()
43    }
44
45    #[must_use]
46    pub fn from_wire(s: &str) -> Option<Self> {
47        s.parse().ok()
48    }
49
50    /// `true` for the SCRAM mechanisms, SHA-256 and SHA-512.
51    ///
52    /// The handshake and authenticate code uses this where it treats both the
53    /// same way at the dispatch level.
54    #[must_use]
55    pub fn is_scram(self) -> bool {
56        matches!(self, Self::ScramSha256 | Self::ScramSha512)
57    }
58
59    /// Every mechanism, in declaration order.
60    pub const ALL: [Self; 5] = [
61        Self::Plain,
62        Self::ScramSha256,
63        Self::ScramSha512,
64        Self::OAuthBearer,
65        Self::Gssapi,
66    ];
67
68    /// `true` when this build can run the mechanism.
69    ///
70    /// GSSAPI needs the `gssapi` Cargo feature on a native target. Every other
71    /// mechanism is always available.
72    #[must_use]
73    pub const fn is_available(self) -> bool {
74        match self {
75            Self::Gssapi => cfg!(all(feature = "gssapi", not(target_family = "wasm"))),
76            Self::Plain | Self::ScramSha256 | Self::ScramSha512 | Self::OAuthBearer => true,
77        }
78    }
79
80    /// The mechanisms this build can run, in declaration order.
81    pub fn available() -> impl Iterator<Item = Self> {
82        Self::ALL.into_iter().filter(|m| m.is_available())
83    }
84
85    /// Returns `self` when this build can run the mechanism.
86    ///
87    /// # Errors
88    ///
89    /// [`AuthError::UnsupportedMechanism`] when [`SaslMechanism::is_available`]
90    /// is `false`. A broker answers that with Kafka's
91    /// `UNSUPPORTED_SASL_MECHANISM` (error code 33), as it does for a mechanism
92    /// the listener has not enabled.
93    pub const fn require_available(self) -> Result<Self, AuthError> {
94        if self.is_available() {
95            Ok(self)
96        } else {
97            Err(AuthError::UnsupportedMechanism)
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use assert2::check;
105
106    use super::*;
107
108    #[test]
109    fn wire_name_round_trip() {
110        for m in [
111            SaslMechanism::Plain,
112            SaslMechanism::ScramSha256,
113            SaslMechanism::ScramSha512,
114            SaslMechanism::OAuthBearer,
115        ] {
116            assert2::assert!(SaslMechanism::from_wire(m.wire_name()) == Some(m));
117        }
118    }
119
120    #[test]
121    fn from_wire_unknown_returns_none() {
122        for wire in ["SCRAM-SHA-128", "OAUTH", ""] {
123            assert2::assert!(SaslMechanism::from_wire(wire) == None);
124        }
125    }
126
127    /// Wire-exactness guard: only the canonical Kafka mechanism strings parse.
128    /// The Rust variant names and other casings must NOT parse, so the
129    /// `strum(serialize = ...)` attributes can never silently loosen the SASL
130    /// handshake's accepted mechanism set.
131    #[test]
132    fn from_wire_rejects_variant_names_and_casing() {
133        for wire in [
134            "Plain",
135            "plain",
136            "ScramSha256",
137            "scram-sha-256",
138            "OAuthBearer",
139        ] {
140            assert2::assert!(SaslMechanism::from_wire(wire) == None);
141        }
142    }
143
144    #[test]
145    fn oauthbearer_wire_round_trip() {
146        check!(
147            (
148                SaslMechanism::from_wire("OAUTHBEARER"),
149                SaslMechanism::OAuthBearer.wire_name(),
150                SaslMechanism::OAuthBearer.is_scram(),
151            ) == (Some(SaslMechanism::OAuthBearer), "OAUTHBEARER", false)
152        );
153    }
154
155    #[test]
156    fn gssapi_mechanism_roundtrips_wire_name() {
157        use std::str::FromStr;
158        assert2::assert!(
159            (
160                SaslMechanism::from_str("GSSAPI").unwrap(),
161                SaslMechanism::Gssapi.wire_name(),
162            ) == (SaslMechanism::Gssapi, "GSSAPI")
163        );
164    }
165
166    /// Whether this test build has the GSSAPI provider.
167    const GSSAPI: bool = cfg!(all(feature = "gssapi", not(target_family = "wasm")));
168
169    #[test]
170    fn availability_follows_the_gssapi_feature() {
171        let available = SaslMechanism::ALL.map(|m| (m, m.is_available()));
172        let listed: Vec<_> = SaslMechanism::available().collect();
173        let want_listed: Vec<_> = SaslMechanism::ALL
174            .into_iter()
175            .filter(|m| *m != SaslMechanism::Gssapi || GSSAPI)
176            .collect();
177        assert2::assert!(
178            (available, listed)
179                == (
180                    [
181                        (SaslMechanism::Plain, true),
182                        (SaslMechanism::ScramSha256, true),
183                        (SaslMechanism::ScramSha512, true),
184                        (SaslMechanism::OAuthBearer, true),
185                        (SaslMechanism::Gssapi, GSSAPI),
186                    ],
187                    want_listed,
188                )
189        );
190    }
191
192    #[test]
193    fn require_available_rejects_gssapi_without_the_feature() {
194        let want_gssapi = GSSAPI
195            .then_some(SaslMechanism::Gssapi)
196            .ok_or(AuthError::UnsupportedMechanism);
197        assert2::assert!(
198            (
199                SaslMechanism::Gssapi.require_available(),
200                SaslMechanism::Plain.require_available(),
201            ) == (want_gssapi, Ok(SaslMechanism::Plain))
202        );
203    }
204
205    #[test]
206    fn is_scram_predicate() {
207        for (_case, mechanism, want) in [
208            ("PLAIN", SaslMechanism::Plain, false),
209            ("SCRAM SHA-256", SaslMechanism::ScramSha256, true),
210            ("SCRAM SHA-512", SaslMechanism::ScramSha512, true),
211        ] {
212            assert2::assert!(mechanism.is_scram() == want);
213        }
214    }
215}