Skip to main content

crabka_security/
principal.rs

1use crate::SaslMechanism;
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5/// How a [`Principal`] was authenticated. A strict superset of
6/// [`SaslMechanism`] that also covers mTLS client-cert authentication
7/// and the implicit ANONYMOUS path on PLAINTEXT / SSL-no-mTLS
8/// listeners.
9///
10/// Kept distinct from `SaslMechanism` because the latter has a
11/// `from_wire`/`wire_name` contract and is stored verbatim in
12/// `V1ScramCredential` metadata records — neither applies to mTLS.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum AuthMethod {
15    /// Anonymous (no SASL, no mTLS). Used for PLAINTEXT listeners and
16    /// for SSL listeners where the client did not present a cert.
17    Anonymous,
18    /// SASL/PLAIN.
19    SaslPlain,
20    /// SASL/SCRAM-SHA-256.
21    SaslScramSha256,
22    /// SASL/SCRAM-SHA-512.
23    SaslScramSha512,
24    /// SASL/OAUTHBEARER.
25    SaslOAuthBearer,
26    /// SASL/GSSAPI (Kerberos, RFC 4752).
27    SaslGssapi,
28    /// mTLS client-cert verified against the listener's
29    /// `client_ca_path`.
30    MTls,
31}
32
33impl AuthMethod {
34    /// Map a SASL `SaslMechanism` onto its `AuthMethod` equivalent.
35    #[must_use]
36    pub fn from_sasl(m: SaslMechanism) -> Self {
37        match m {
38            SaslMechanism::Plain => Self::SaslPlain,
39            SaslMechanism::ScramSha256 => Self::SaslScramSha256,
40            SaslMechanism::ScramSha512 => Self::SaslScramSha512,
41            SaslMechanism::OAuthBearer => Self::SaslOAuthBearer,
42            SaslMechanism::Gssapi => Self::SaslGssapi,
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Principal {
49    pub name: String,
50    pub auth_method: AuthMethod,
51    /// OAuth-derived group memberships from the listener's
52    /// `groupsClaim`. Empty vec for non-OAuth principals (PLAIN/SCRAM/
53    /// mTLS/anonymous) and for OAuth principals whose listener has no
54    /// `groupsClaim` configured. No broker-side authorizer reads this
55    /// yet; populated as scaffolding + for observability.
56    pub groups: Vec<String>,
57}
58
59impl Principal {
60    /// Project a runtime session [`Principal`] onto the Kafka wire-level
61    /// [`KafkaPrincipal`] (`principalType:name`) used by ACLs and
62    /// delegation-token records. All authenticated callers ride under
63    /// `principal_type = "User"`, matching Kafka's
64    /// `DefaultKafkaPrincipalBuilder`.
65    #[must_use]
66    pub fn to_kafka(&self) -> KafkaPrincipal {
67        KafkaPrincipal {
68            principal_type: "User".to_string(),
69            name: self.name.clone(),
70        }
71    }
72}
73
74/// KIP-48: Kafka wire-level principal — the `(principalType,
75/// name)` pair carried in delegation-token records, ACL entries, and
76/// `KafkaPrincipal`-shaped fields across the Kafka protocol. Distinct
77/// from [`Principal`] which models the *runtime session* identity
78/// (auth method + OAuth groups). Format-stable: `Display`/`FromStr`
79/// round-trip the canonical `Type:Name` form.
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub struct KafkaPrincipal {
82    pub principal_type: String,
83    pub name: String,
84}
85
86impl std::fmt::Display for KafkaPrincipal {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{}:{}", self.principal_type, self.name)
89    }
90}
91
92impl std::str::FromStr for KafkaPrincipal {
93    type Err = String;
94    fn from_str(s: &str) -> Result<Self, String> {
95        let (pt, n) = s
96            .split_once(':')
97            .ok_or_else(|| format!("invalid principal {s:?}"))?;
98        Ok(Self {
99            principal_type: pt.into(),
100            name: n.into(),
101        })
102    }
103}
104
105#[derive(Debug, Error, Clone, PartialEq, Eq)]
106pub enum AuthError {
107    #[error("unknown user")]
108    UnknownUser,
109    #[error("bad password")]
110    BadPassword,
111    #[error("bad proof")]
112    BadProof,
113    #[error("malformed message")]
114    MalformedMessage,
115    #[error("unsupported mechanism")]
116    UnsupportedMechanism,
117    /// OAUTHBEARER token failed validation (expired, bad claims, signed token
118    /// rejected by the unsecured validator, missing principal, …). Maps to the
119    /// RFC 7628 `invalid_token` server error status.
120    #[error("invalid token")]
121    InvalidToken,
122    /// OAUTHBEARER introspection HTTP round-trip failed at the transport layer.
123    /// Distinct from `InvalidToken` so the SASL handler can
124    /// surface "`IdP` unreachable" separately from "client supplied a bad
125    /// token". Maps to the RFC 7628 `invalid_token` server error status.
126    #[error("oauthbearer introspection transport: {0}")]
127    IntrospectionTransport(String),
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use assert2::assert;
134
135    #[test]
136    fn from_sasl_mapping() {
137        assert!(AuthMethod::from_sasl(SaslMechanism::Plain) == AuthMethod::SaslPlain);
138        assert!(AuthMethod::from_sasl(SaslMechanism::ScramSha256) == AuthMethod::SaslScramSha256);
139        assert!(AuthMethod::from_sasl(SaslMechanism::ScramSha512) == AuthMethod::SaslScramSha512);
140        assert!(AuthMethod::from_sasl(SaslMechanism::OAuthBearer) == AuthMethod::SaslOAuthBearer);
141        assert!(AuthMethod::from_sasl(SaslMechanism::Gssapi) == AuthMethod::SaslGssapi);
142    }
143}