use secrecy::SecretString;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum SecurityContextBuildError {
#[error(
"subject_id is required - use SecurityContext::anonymous() for unauthenticated contexts"
)]
MissingSubjectId,
#[error(
"subject_tenant_id is required - use SecurityContext::anonymous() for unauthenticated contexts"
)]
MissingSubjectTenantId,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SecurityContext {
subject_id: Uuid,
subject_type: Option<String>,
subject_tenant_id: Uuid,
#[serde(default)]
token_scopes: Vec<String>,
#[serde(skip)]
bearer_token: Option<SecretString>,
}
impl SecurityContext {
#[must_use]
pub fn builder() -> SecurityContextBuilder {
SecurityContextBuilder::default()
}
#[must_use]
pub fn anonymous() -> Self {
Self {
subject_id: Uuid::default(),
subject_type: None,
subject_tenant_id: Uuid::default(),
token_scopes: Vec::new(),
bearer_token: None,
}
}
#[must_use]
pub fn subject_id(&self) -> Uuid {
self.subject_id
}
#[must_use]
pub fn subject_type(&self) -> Option<&str> {
self.subject_type.as_deref()
}
#[must_use]
pub fn subject_tenant_id(&self) -> Uuid {
self.subject_tenant_id
}
#[must_use]
pub fn token_scopes(&self) -> &[String] {
&self.token_scopes
}
#[must_use]
pub fn bearer_token(&self) -> Option<&SecretString> {
self.bearer_token.as_ref()
}
}
#[derive(Default)]
pub struct SecurityContextBuilder {
subject_id: Option<Uuid>,
subject_type: Option<String>,
subject_tenant_id: Option<Uuid>,
token_scopes: Vec<String>,
bearer_token: Option<SecretString>,
}
impl SecurityContextBuilder {
#[must_use]
pub fn subject_id(mut self, subject_id: Uuid) -> Self {
self.subject_id = Some(subject_id);
self
}
#[must_use]
pub fn subject_type(mut self, subject_type: &str) -> Self {
self.subject_type = Some(subject_type.to_owned());
self
}
#[must_use]
pub fn subject_tenant_id(mut self, subject_tenant_id: Uuid) -> Self {
self.subject_tenant_id = Some(subject_tenant_id);
self
}
#[must_use]
pub fn token_scopes(mut self, scopes: Vec<String>) -> Self {
self.token_scopes = scopes;
self
}
#[must_use]
pub fn bearer_token(mut self, token: impl Into<SecretString>) -> Self {
self.bearer_token = Some(token.into());
self
}
pub fn build(self) -> Result<SecurityContext, SecurityContextBuildError> {
let subject_id = self
.subject_id
.ok_or(SecurityContextBuildError::MissingSubjectId)?;
let subject_tenant_id = self
.subject_tenant_id
.ok_or(SecurityContextBuildError::MissingSubjectTenantId)?;
Ok(SecurityContext {
subject_id,
subject_type: self.subject_type,
subject_tenant_id,
token_scopes: self.token_scopes,
bearer_token: self.bearer_token,
})
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use secrecy::ExposeSecret;
use super::*;
#[test]
fn test_security_context_builder_full() {
let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
let ctx = SecurityContext::builder()
.subject_id(subject_id)
.subject_type("user")
.subject_tenant_id(subject_tenant_id)
.token_scopes(vec!["read:events".to_owned(), "write:events".to_owned()])
.bearer_token("test-token-123".to_owned())
.build()
.unwrap();
assert_eq!(ctx.subject_id(), subject_id);
assert_eq!(ctx.subject_tenant_id(), subject_tenant_id);
assert_eq!(ctx.token_scopes(), &["read:events", "write:events"]);
assert_eq!(
ctx.bearer_token().map(ExposeSecret::expose_secret),
Some("test-token-123"),
);
}
#[test]
fn test_security_context_builder_missing_subject_id() {
let err = SecurityContext::builder()
.subject_tenant_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap())
.build();
assert!(matches!(
err,
Err(SecurityContextBuildError::MissingSubjectId)
));
}
#[test]
fn test_security_context_builder_missing_tenant_id() {
let err = SecurityContext::builder()
.subject_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap())
.build();
assert!(matches!(
err,
Err(SecurityContextBuildError::MissingSubjectTenantId)
));
}
#[test]
fn test_security_context_builder_missing_both() {
let err = SecurityContext::builder().build();
assert!(matches!(
err,
Err(SecurityContextBuildError::MissingSubjectId)
));
}
#[test]
fn test_security_context_anonymous() {
let ctx = SecurityContext::anonymous();
assert_eq!(ctx.subject_id(), Uuid::default());
assert_eq!(ctx.subject_tenant_id(), Uuid::default());
assert!(ctx.token_scopes().is_empty());
assert!(ctx.bearer_token().is_none());
}
#[test]
fn test_security_context_builder_chaining() {
let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
let ctx = SecurityContext::builder()
.subject_id(subject_id)
.subject_type("user")
.subject_tenant_id(subject_tenant_id)
.build()
.unwrap();
assert_eq!(ctx.subject_id(), subject_id);
}
#[test]
fn test_security_context_clone() {
let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
let ctx1 = SecurityContext::builder()
.subject_id(subject_id)
.subject_tenant_id(subject_tenant_id)
.token_scopes(vec!["*".to_owned()])
.bearer_token("secret".to_owned())
.build()
.unwrap();
let ctx2 = ctx1.clone();
assert_eq!(ctx2.subject_id(), ctx1.subject_id());
assert_eq!(ctx2.subject_tenant_id(), ctx1.subject_tenant_id());
assert_eq!(ctx2.token_scopes(), ctx1.token_scopes());
assert_eq!(
ctx2.bearer_token().map(ExposeSecret::expose_secret),
ctx1.bearer_token().map(ExposeSecret::expose_secret),
);
}
#[test]
fn test_security_context_serialize_deserialize() {
let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
let original = SecurityContext::builder()
.subject_id(subject_id)
.subject_type("user")
.subject_tenant_id(subject_tenant_id)
.token_scopes(vec!["admin".to_owned()])
.bearer_token("secret-token".to_owned())
.build()
.unwrap();
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: SecurityContext = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.subject_id(), original.subject_id());
assert_eq!(
deserialized.subject_tenant_id(),
original.subject_tenant_id()
);
assert_eq!(deserialized.token_scopes(), original.token_scopes());
assert!(deserialized.bearer_token().is_none());
}
#[test]
fn test_security_context_bearer_token_not_serialized() {
let ctx = SecurityContext::anonymous();
let serialized = serde_json::to_string(&ctx).unwrap();
assert!(!serialized.contains("bearer_token"));
}
#[test]
fn test_security_context_empty_scopes() {
let ctx = SecurityContext::anonymous();
assert!(ctx.token_scopes().is_empty());
}
}