modkit_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)]
34 token_scopes: Vec<String>,
35 #[serde(skip)]
38 bearer_token: Option<SecretString>,
39}
40
41impl SecurityContext {
42 #[must_use]
44 pub fn builder() -> SecurityContextBuilder {
45 SecurityContextBuilder::default()
46 }
47
48 #[must_use]
53 pub fn anonymous() -> Self {
54 Self {
55 subject_id: Uuid::default(),
56 subject_type: None,
57 subject_tenant_id: Uuid::default(),
58 token_scopes: Vec::new(),
59 bearer_token: None,
60 }
61 }
62
63 #[must_use]
65 pub fn subject_id(&self) -> Uuid {
66 self.subject_id
67 }
68
69 #[must_use]
71 pub fn subject_type(&self) -> Option<&str> {
72 self.subject_type.as_deref()
73 }
74
75 #[must_use]
77 pub fn subject_tenant_id(&self) -> Uuid {
78 self.subject_tenant_id
79 }
80
81 #[must_use]
83 pub fn token_scopes(&self) -> &[String] {
84 &self.token_scopes
85 }
86
87 #[must_use]
89 pub fn bearer_token(&self) -> Option<&SecretString> {
90 self.bearer_token.as_ref()
91 }
92}
93
94#[derive(Default)]
95pub struct SecurityContextBuilder {
96 subject_id: Option<Uuid>,
97 subject_type: Option<String>,
98 subject_tenant_id: Option<Uuid>,
99 token_scopes: Vec<String>,
100 bearer_token: Option<SecretString>,
101}
102
103impl SecurityContextBuilder {
104 #[must_use]
105 pub fn subject_id(mut self, subject_id: Uuid) -> Self {
106 self.subject_id = Some(subject_id);
107 self
108 }
109
110 #[must_use]
111 pub fn subject_type(mut self, subject_type: &str) -> Self {
112 self.subject_type = Some(subject_type.to_owned());
113 self
114 }
115
116 #[must_use]
117 pub fn subject_tenant_id(mut self, subject_tenant_id: Uuid) -> Self {
118 self.subject_tenant_id = Some(subject_tenant_id);
119 self
120 }
121
122 #[must_use]
123 pub fn token_scopes(mut self, scopes: Vec<String>) -> Self {
124 self.token_scopes = scopes;
125 self
126 }
127
128 #[must_use]
129 pub fn bearer_token(mut self, token: impl Into<SecretString>) -> Self {
130 self.bearer_token = Some(token.into());
131 self
132 }
133
134 pub fn build(self) -> Result<SecurityContext, SecurityContextBuildError> {
142 let subject_id = self
143 .subject_id
144 .ok_or(SecurityContextBuildError::MissingSubjectId)?;
145 let subject_tenant_id = self
146 .subject_tenant_id
147 .ok_or(SecurityContextBuildError::MissingSubjectTenantId)?;
148 Ok(SecurityContext {
149 subject_id,
150 subject_type: self.subject_type,
151 subject_tenant_id,
152 token_scopes: self.token_scopes,
153 bearer_token: self.bearer_token,
154 })
155 }
156}
157
158#[cfg(test)]
159#[cfg_attr(coverage_nightly, coverage(off))]
160mod tests {
161 use secrecy::ExposeSecret;
162
163 use super::*;
164
165 #[test]
166 fn test_security_context_builder_full() {
167 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
168 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
169
170 let ctx = SecurityContext::builder()
171 .subject_id(subject_id)
172 .subject_type("user")
173 .subject_tenant_id(subject_tenant_id)
174 .token_scopes(vec!["read:events".to_owned(), "write:events".to_owned()])
175 .bearer_token("test-token-123".to_owned())
176 .build()
177 .unwrap();
178
179 assert_eq!(ctx.subject_id(), subject_id);
180 assert_eq!(ctx.subject_tenant_id(), subject_tenant_id);
181 assert_eq!(ctx.token_scopes(), &["read:events", "write:events"]);
182 assert_eq!(
183 ctx.bearer_token().map(ExposeSecret::expose_secret),
184 Some("test-token-123"),
185 );
186 }
187
188 #[test]
189 fn test_security_context_builder_missing_subject_id() {
190 let err = SecurityContext::builder()
191 .subject_tenant_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap())
192 .build();
193
194 assert!(matches!(
195 err,
196 Err(SecurityContextBuildError::MissingSubjectId)
197 ));
198 }
199
200 #[test]
201 fn test_security_context_builder_missing_tenant_id() {
202 let err = SecurityContext::builder()
203 .subject_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap())
204 .build();
205
206 assert!(matches!(
207 err,
208 Err(SecurityContextBuildError::MissingSubjectTenantId)
209 ));
210 }
211
212 #[test]
213 fn test_security_context_builder_missing_both() {
214 let err = SecurityContext::builder().build();
215
216 assert!(matches!(
217 err,
218 Err(SecurityContextBuildError::MissingSubjectId)
219 ));
220 }
221
222 #[test]
223 fn test_security_context_anonymous() {
224 let ctx = SecurityContext::anonymous();
225
226 assert_eq!(ctx.subject_id(), Uuid::default());
227 assert_eq!(ctx.subject_tenant_id(), Uuid::default());
228 assert!(ctx.token_scopes().is_empty());
229 assert!(ctx.bearer_token().is_none());
230 }
231
232 #[test]
233 fn test_security_context_builder_chaining() {
234 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
235 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
236
237 let ctx = SecurityContext::builder()
238 .subject_id(subject_id)
239 .subject_type("user")
240 .subject_tenant_id(subject_tenant_id)
241 .build()
242 .unwrap();
243
244 assert_eq!(ctx.subject_id(), subject_id);
245 }
246
247 #[test]
248 fn test_security_context_clone() {
249 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
250 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
251
252 let ctx1 = SecurityContext::builder()
253 .subject_id(subject_id)
254 .subject_tenant_id(subject_tenant_id)
255 .token_scopes(vec!["*".to_owned()])
256 .bearer_token("secret".to_owned())
257 .build()
258 .unwrap();
259
260 let ctx2 = ctx1.clone();
261
262 assert_eq!(ctx2.subject_id(), ctx1.subject_id());
263 assert_eq!(ctx2.subject_tenant_id(), ctx1.subject_tenant_id());
264 assert_eq!(ctx2.token_scopes(), ctx1.token_scopes());
265 assert_eq!(
266 ctx2.bearer_token().map(ExposeSecret::expose_secret),
267 ctx1.bearer_token().map(ExposeSecret::expose_secret),
268 );
269 }
270
271 #[test]
272 fn test_security_context_serialize_deserialize() {
273 let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
274 let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
275
276 let original = SecurityContext::builder()
277 .subject_id(subject_id)
278 .subject_type("user")
279 .subject_tenant_id(subject_tenant_id)
280 .token_scopes(vec!["admin".to_owned()])
281 .bearer_token("secret-token".to_owned())
282 .build()
283 .unwrap();
284
285 let serialized = serde_json::to_string(&original).unwrap();
286 let deserialized: SecurityContext = serde_json::from_str(&serialized).unwrap();
287
288 assert_eq!(deserialized.subject_id(), original.subject_id());
289 assert_eq!(
290 deserialized.subject_tenant_id(),
291 original.subject_tenant_id()
292 );
293 assert_eq!(deserialized.token_scopes(), original.token_scopes());
294 assert!(deserialized.bearer_token().is_none());
296 }
297
298 #[test]
299 fn test_security_context_bearer_token_not_serialized() {
300 let ctx = SecurityContext::anonymous();
301
302 let serialized = serde_json::to_string(&ctx).unwrap();
303 assert!(!serialized.contains("bearer_token"));
304 }
305
306 #[test]
307 fn test_security_context_empty_scopes() {
308 let ctx = SecurityContext::anonymous();
309
310 assert!(ctx.token_scopes().is_empty());
311 }
312}