toolkit_security/
context.rs1use secrecy::SecretString;
2use uuid::Uuid;
3
4#[derive(Debug, thiserror::Error)]
7pub enum SecurityContextBuildError {
8 #[error(
9 "subject_id is required - use SecurityContext::anonymous() for unauthenticated contexts"
10 )]
11 MissingSubjectId,
12 #[error(
13 "subject_tenant_id is required - use SecurityContext::anonymous() for unauthenticated contexts"
14 )]
15 MissingSubjectTenantId,
16}
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct SecurityContext {
24 subject_id: Uuid,
26 subject_type: Option<String>,
28 subject_tenant_id: Uuid,
31 #[serde(default)]
42 token_scopes: Vec<String>,
43 #[serde(skip)]
46 bearer_token: Option<SecretString>,
47}
48
49impl SecurityContext {
50 #[must_use]
52 pub fn builder() -> SecurityContextBuilder {
53 SecurityContextBuilder::default()
54 }
55
56 #[must_use]
61 pub fn anonymous() -> Self {
62 Self {
63 subject_id: Uuid::default(),
64 subject_type: None,
65 subject_tenant_id: Uuid::default(),
66 token_scopes: Vec::new(),
67 bearer_token: None,
68 }
69 }
70
71 #[must_use]
73 pub fn subject_id(&self) -> Uuid {
74 self.subject_id
75 }
76
77 #[must_use]
79 pub fn subject_type(&self) -> Option<&str> {
80 self.subject_type.as_deref()
81 }
82
83 #[must_use]
85 pub fn subject_tenant_id(&self) -> Uuid {
86 self.subject_tenant_id
87 }
88
89 #[must_use]
91 pub fn token_scopes(&self) -> &[String] {
92 &self.token_scopes
93 }
94
95 #[must_use]
105 pub fn is_anonymous(&self) -> bool {
106 self.subject_id.is_nil() || self.subject_tenant_id.is_nil()
107 }
108
109 #[must_use]
117 pub fn has_scope(&self, scope: &str) -> bool {
118 self.token_scopes
119 .iter()
120 .any(|granted| granted == "*" || granted == scope)
121 }
122
123 #[must_use]
125 pub fn bearer_token(&self) -> Option<&SecretString> {
126 self.bearer_token.as_ref()
127 }
128}
129
130#[derive(Debug, Default)]
141pub struct SecurityContextBuilder {
142 subject_id: Option<Uuid>,
143 subject_type: Option<String>,
144 subject_tenant_id: Option<Uuid>,
145 token_scopes: Vec<String>,
146 bearer_token: Option<SecretString>,
147}
148
149impl SecurityContextBuilder {
150 #[must_use]
152 pub fn subject_id(mut self, subject_id: Uuid) -> Self {
153 self.subject_id = Some(subject_id);
154 self
155 }
156
157 #[must_use]
163 pub fn subject_type(mut self, subject_type: &str) -> Self {
164 self.subject_type = Some(subject_type.to_owned());
165 self
166 }
167
168 #[must_use]
170 pub fn subject_tenant_id(mut self, subject_tenant_id: Uuid) -> Self {
171 self.subject_tenant_id = Some(subject_tenant_id);
172 self
173 }
174
175 #[must_use]
180 pub fn token_scopes(mut self, scopes: Vec<String>) -> Self {
181 self.token_scopes = scopes;
182 self
183 }
184
185 #[must_use]
188 pub fn bearer_token(mut self, token: impl Into<SecretString>) -> Self {
189 self.bearer_token = Some(token.into());
190 self
191 }
192
193 pub fn build(self) -> Result<SecurityContext, SecurityContextBuildError> {
201 let subject_id = self
202 .subject_id
203 .ok_or(SecurityContextBuildError::MissingSubjectId)?;
204 let subject_tenant_id = self
205 .subject_tenant_id
206 .ok_or(SecurityContextBuildError::MissingSubjectTenantId)?;
207 Ok(SecurityContext {
208 subject_id,
209 subject_type: self.subject_type,
210 subject_tenant_id,
211 token_scopes: self.token_scopes,
212 bearer_token: self.bearer_token,
213 })
214 }
215}
216
217#[cfg(test)]
218#[cfg_attr(coverage_nightly, coverage(off))]
219mod tests {
220 use secrecy::ExposeSecret;
221
222 use super::*;
223
224 #[test]
225 fn test_security_context_builder_full() {
226 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
227 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
228
229 let ctx = SecurityContext::builder()
230 .subject_id(subject_id)
231 .subject_type("user")
232 .subject_tenant_id(subject_tenant_id)
233 .token_scopes(vec!["read:events".to_owned(), "write:events".to_owned()])
234 .bearer_token("test-token-123".to_owned())
235 .build()
236 .unwrap();
237
238 assert_eq!(ctx.subject_id(), subject_id);
239 assert_eq!(ctx.subject_tenant_id(), subject_tenant_id);
240 assert_eq!(ctx.token_scopes(), &["read:events", "write:events"]);
241 assert_eq!(
242 ctx.bearer_token().map(ExposeSecret::expose_secret),
243 Some("test-token-123"),
244 );
245 }
246
247 #[test]
248 fn test_security_context_builder_missing_subject_id() {
249 let err = SecurityContext::builder()
250 .subject_tenant_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap())
251 .build();
252
253 assert!(matches!(
254 err,
255 Err(SecurityContextBuildError::MissingSubjectId)
256 ));
257 }
258
259 #[test]
260 fn test_security_context_builder_missing_tenant_id() {
261 let err = SecurityContext::builder()
262 .subject_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap())
263 .build();
264
265 assert!(matches!(
266 err,
267 Err(SecurityContextBuildError::MissingSubjectTenantId)
268 ));
269 }
270
271 #[test]
272 fn test_security_context_builder_missing_both() {
273 let err = SecurityContext::builder().build();
274
275 assert!(matches!(
276 err,
277 Err(SecurityContextBuildError::MissingSubjectId)
278 ));
279 }
280
281 #[test]
282 fn test_security_context_anonymous() {
283 let ctx = SecurityContext::anonymous();
284
285 assert_eq!(ctx.subject_id(), Uuid::default());
286 assert_eq!(ctx.subject_tenant_id(), Uuid::default());
287 assert!(ctx.token_scopes().is_empty());
288 assert!(ctx.bearer_token().is_none());
289 }
290
291 #[test]
292 fn test_security_context_builder_keeps_the_last_value_set() {
293 let first = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
298 let second = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440003").unwrap();
299 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
300
301 let ctx = SecurityContext::builder()
302 .subject_id(first)
303 .subject_type("user")
304 .subject_id(second)
305 .subject_tenant_id(subject_tenant_id)
306 .token_scopes(vec!["read".to_owned()])
307 .token_scopes(vec!["write".to_owned()])
308 .build()
309 .unwrap();
310
311 assert_eq!(ctx.subject_id(), second);
312 assert_eq!(ctx.token_scopes(), ["write".to_owned()]);
313 }
314
315 #[test]
316 fn test_security_context_clone() {
317 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
318 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
319
320 let ctx1 = SecurityContext::builder()
321 .subject_id(subject_id)
322 .subject_tenant_id(subject_tenant_id)
323 .token_scopes(vec!["*".to_owned()])
324 .bearer_token("secret".to_owned())
325 .build()
326 .unwrap();
327
328 let ctx2 = ctx1.clone();
329
330 assert_eq!(ctx2.subject_id(), ctx1.subject_id());
331 assert_eq!(ctx2.subject_tenant_id(), ctx1.subject_tenant_id());
332 assert_eq!(ctx2.token_scopes(), ctx1.token_scopes());
333 assert_eq!(
334 ctx2.bearer_token().map(ExposeSecret::expose_secret),
335 ctx1.bearer_token().map(ExposeSecret::expose_secret),
336 );
337 }
338
339 #[test]
340 fn test_security_context_serialize_deserialize() {
341 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
342 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
343
344 let original = SecurityContext::builder()
345 .subject_id(subject_id)
346 .subject_type("user")
347 .subject_tenant_id(subject_tenant_id)
348 .token_scopes(vec!["admin".to_owned()])
349 .bearer_token("secret-token".to_owned())
350 .build()
351 .unwrap();
352
353 let serialized = serde_json::to_string(&original).unwrap();
354 let deserialized: SecurityContext = serde_json::from_str(&serialized).unwrap();
355
356 assert_eq!(deserialized.subject_id(), original.subject_id());
357 assert_eq!(
358 deserialized.subject_tenant_id(),
359 original.subject_tenant_id()
360 );
361 assert_eq!(deserialized.token_scopes(), original.token_scopes());
362 assert!(deserialized.bearer_token().is_none());
364 }
365
366 #[test]
367 fn test_security_context_bearer_token_not_serialized() {
368 let ctx = SecurityContext::builder()
373 .subject_id(Uuid::from_u128(1))
374 .subject_tenant_id(Uuid::from_u128(2))
375 .bearer_token("super-secret-token")
376 .build()
377 .unwrap();
378 assert!(ctx.bearer_token().is_some(), "guard: the token is set");
379
380 let serialized = serde_json::to_string(&ctx).unwrap();
381 assert!(
382 !serialized.contains("bearer_token"),
383 "the field name must not appear: {serialized}"
384 );
385 assert!(
386 !serialized.contains("super-secret-token"),
387 "the token value must not appear: {serialized}"
388 );
389 }
390
391 #[test]
392 fn is_anonymous_separates_an_unauthenticated_context_from_a_real_subject() {
393 assert!(SecurityContext::anonymous().is_anonymous());
394
395 let authenticated = SecurityContext::builder()
396 .subject_id(Uuid::from_u128(1))
397 .subject_tenant_id(Uuid::from_u128(2))
398 .build()
399 .unwrap();
400 assert!(!authenticated.is_anonymous());
401
402 let no_tenant = SecurityContext::builder()
405 .subject_id(Uuid::from_u128(1))
406 .subject_tenant_id(Uuid::nil())
407 .build()
408 .unwrap();
409 assert!(no_tenant.is_anonymous());
410 }
411
412 #[test]
413 fn has_scope_treats_empty_as_no_capability_and_wildcard_as_all() {
414 let build = |scopes: Vec<String>| {
415 SecurityContext::builder()
416 .subject_id(Uuid::from_u128(1))
417 .subject_tenant_id(Uuid::from_u128(2))
418 .token_scopes(scopes)
419 .build()
420 .unwrap()
421 };
422
423 let none = build(Vec::new());
424 assert!(
425 !none.has_scope("read:events"),
426 "an empty scope list grants nothing; it once read as unrestricted"
427 );
428
429 let wildcard = build(vec!["*".to_owned()]);
430 assert!(wildcard.has_scope("read:events"));
431 assert!(wildcard.has_scope("anything-at-all"));
432
433 let specific = build(vec!["read:events".to_owned()]);
434 assert!(specific.has_scope("read:events"));
435 assert!(!specific.has_scope("write:events"));
436 }
437
438 #[test]
439 fn test_security_context_empty_scopes() {
440 let ctx = SecurityContext::builder()
443 .subject_id(Uuid::from_u128(1))
444 .subject_tenant_id(Uuid::from_u128(2))
445 .token_scopes(Vec::new())
446 .build()
447 .unwrap();
448
449 assert!(ctx.token_scopes().is_empty());
450 }
451}