use std::collections::BTreeMap;
use std::slice;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaimValue {
One(String),
Many(Vec<String>),
}
impl ClaimValue {
#[must_use]
pub fn one(value: &str) -> Self {
Self::One(value.to_owned())
}
#[must_use]
pub fn many<'a>(values: impl IntoIterator<Item = &'a str>) -> Self {
Self::Many(values.into_iter().map(str::to_owned).collect())
}
#[must_use]
pub fn as_slice(&self) -> &[String] {
match self {
ClaimValue::One(value) => slice::from_ref(value),
ClaimValue::Many(values) => values.as_slice(),
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
ClaimValue::One(value) => Some(value.as_str()),
ClaimValue::Many(_) => None,
}
}
#[must_use]
pub fn contains(&self, value: &str) -> bool {
self.as_slice().iter().any(|v| v == value)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct AuthorizedIdentity {
principal: Option<String>,
scheme: Option<String>,
claims: BTreeMap<String, ClaimValue>,
}
impl AuthorizedIdentity {
pub const CLAIM_SUBJECT: &'static str = "sub";
pub const CLAIM_AUDIENCE: &'static str = "aud";
#[must_use]
pub const fn new() -> Self {
Self {
principal: None,
scheme: None,
claims: BTreeMap::new(),
}
}
#[must_use]
pub fn with_principal(mut self, principal: &str) -> Self {
self.principal = Some(principal.to_owned());
self
}
#[must_use]
pub fn with_scheme(mut self, scheme: &str) -> Self {
self.scheme = Some(scheme.to_owned());
self
}
#[must_use]
pub fn with_claim(mut self, name: &str, value: ClaimValue) -> Self {
let _ = self.claims.insert(name.to_owned(), value);
self
}
#[must_use]
pub fn with_claim_str(self, name: &str, value: &str) -> Self {
self.with_claim(name, ClaimValue::one(value))
}
#[must_use]
pub fn with_claim_values<'a>(
self,
name: &str,
values: impl IntoIterator<Item = &'a str>,
) -> Self {
self.with_claim(name, ClaimValue::many(values))
}
#[must_use]
pub fn with_subject(self, subject: &str) -> Self {
self.with_claim_str(Self::CLAIM_SUBJECT, subject)
}
#[must_use]
pub fn with_audience(self, audience: &str) -> Self {
self.with_claim_str(Self::CLAIM_AUDIENCE, audience)
}
#[must_use]
pub fn principal(&self) -> Option<&str> {
self.principal.as_deref()
}
#[must_use]
pub fn scheme(&self) -> Option<&str> {
self.scheme.as_deref()
}
#[must_use]
pub fn claim(&self, name: &str) -> Option<&ClaimValue> {
self.claims.get(name)
}
#[must_use]
pub fn claim_str(&self, name: &str) -> Option<&str> {
self.claim(name).and_then(ClaimValue::as_str)
}
pub fn claims(&self) -> impl Iterator<Item = (&str, &ClaimValue)> {
self.claims
.iter()
.map(|(name, value)| (name.as_str(), value))
}
#[must_use]
pub fn subject(&self) -> Option<&str> {
self.claim_str(Self::CLAIM_SUBJECT)
}
#[must_use]
pub fn audience(&self) -> Option<&str> {
self.claim_str(Self::CLAIM_AUDIENCE)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subject_audience_are_sugar_over_claims() {
let identity = AuthorizedIdentity::new()
.with_subject("system:serviceaccount:default:my-sa")
.with_audience("https://my-service.example");
assert_eq!(
identity.subject(),
Some("system:serviceaccount:default:my-sa")
);
assert_eq!(identity.audience(), Some("https://my-service.example"));
assert_eq!(
identity.claim_str("sub"),
Some("system:serviceaccount:default:my-sa")
);
assert_eq!(
identity.claim_str("aud"),
Some("https://my-service.example")
);
let empty = AuthorizedIdentity::new();
assert_eq!(empty.subject(), None);
assert_eq!(empty.audience(), None);
assert_eq!(empty.principal(), None);
assert_eq!(empty.scheme(), None);
assert_eq!(empty.claims().count(), 0);
}
#[test]
fn principal_scheme_and_multi_valued_claims_round_trip() {
let identity = AuthorizedIdentity::new()
.with_principal("system:serviceaccount:team-a:sender")
.with_scheme("k8s_sat")
.with_subject("system:serviceaccount:team-a:sender")
.with_claim_str("k8s.namespace", "team-a")
.with_claim_values(
"groups",
["system:serviceaccounts", "system:serviceaccounts:team-a"],
);
assert_eq!(
identity.principal(),
Some("system:serviceaccount:team-a:sender")
);
assert_eq!(identity.scheme(), Some("k8s_sat"));
assert_eq!(identity.claim_str("k8s.namespace"), Some("team-a"));
let groups = identity.claim("groups").expect("groups present");
assert!(groups.contains("system:serviceaccounts:team-a"));
assert!(!groups.contains("system:serviceaccounts:other"));
assert_eq!(groups.as_str(), None);
assert_eq!(groups.as_slice().len(), 2);
}
#[test]
fn multi_valued_audience_reads_through_claim_not_accessor() {
let identity = AuthorizedIdentity::new().with_claim_values("aud", ["aud-a", "aud-b"]);
assert_eq!(identity.audience(), None);
let aud = identity.claim("aud").expect("aud present");
assert!(aud.contains("aud-a"));
assert!(aud.contains("aud-b"));
assert_eq!(aud.as_slice().len(), 2);
}
#[test]
fn claim_value_slice_and_contains() {
let one = ClaimValue::One("only".to_string());
assert_eq!(one.as_slice(), &["only".to_string()]);
assert!(one.contains("only"));
assert!(!one.contains("nope"));
let many = ClaimValue::Many(vec!["a".to_string(), "b".to_string()]);
assert_eq!(many.as_str(), None);
assert!(many.contains("b"));
assert!(!many.contains("c"));
}
#[test]
fn claim_value_borrowed_constructors() {
assert_eq!(ClaimValue::one("x"), ClaimValue::One("x".to_string()));
assert_eq!(
ClaimValue::many(["a", "b"]),
ClaimValue::Many(vec!["a".to_string(), "b".to_string()])
);
}
}