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}