Skip to main content

toolkit_security/
context.rs

1use secrecy::SecretString;
2use uuid::Uuid;
3
4/// Error returned when `SecurityContextBuilder::build()` is called without
5/// required fields.
6#[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/// `SecurityContext` encapsulates the security-related information for a request or operation.
19///
20/// Built by the `AuthN` Resolver during authentication and passed through the request lifecycle.
21/// Gears use this context together with the `AuthZ` Resolver to obtain access scopes.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct SecurityContext {
24    /// Subject ID — the authenticated user, service, or system making the request.
25    subject_id: Uuid,
26    /// Subject type classification (e.g., "user", "service").
27    subject_type: Option<String>,
28    /// Subject's home tenant (from `AuthN`). Required — every authenticated
29    /// subject belongs to a tenant.
30    subject_tenant_id: Uuid,
31    /// Token capability restrictions. `["*"]` means first-party / unrestricted.
32    ///
33    /// **Empty means no capability was granted, not unrestricted.** This field
34    /// used to be documented the other way round — "treat as unrestricted for
35    /// backward compatibility" — while the gateway enforcer did the opposite
36    /// and fail-closed on an empty list. A consumer following the old wording
37    /// would turn a context carrying no scopes into full access.
38    ///
39    /// Read it through [`SecurityContext::has_scope`] rather than inspecting
40    /// the list, so the `["*"]` rule lives in one place.
41    #[serde(default)]
42    token_scopes: Vec<String>,
43    /// Original bearer token for PDP forwarding. Never serialized/persisted.
44    /// Wrapped in `SecretString` so `Debug` redacts the value automatically.
45    #[serde(skip)]
46    bearer_token: Option<SecretString>,
47}
48
49impl SecurityContext {
50    /// Create a new `SecurityContext` builder
51    #[must_use]
52    pub fn builder() -> SecurityContextBuilder {
53        SecurityContextBuilder::default()
54    }
55
56    /// Create an anonymous `SecurityContext` with no tenant, subject, or permissions.
57    ///
58    /// Use this for unauthenticated / dev / auth-disabled contexts where no
59    /// authenticated subject exists.
60    #[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    /// Get the subject ID (user, service, or system) associated with the security context
72    #[must_use]
73    pub fn subject_id(&self) -> Uuid {
74        self.subject_id
75    }
76
77    /// Get the subject type classification (e.g., "user", "service").
78    #[must_use]
79    pub fn subject_type(&self) -> Option<&str> {
80        self.subject_type.as_deref()
81    }
82
83    /// Get the subject's home tenant ID (from `AuthN` token).
84    #[must_use]
85    pub fn subject_tenant_id(&self) -> Uuid {
86        self.subject_tenant_id
87    }
88
89    /// Get the token scopes. `["*"]` means first-party / unrestricted.
90    #[must_use]
91    pub fn token_scopes(&self) -> &[String] {
92        &self.token_scopes
93    }
94
95    /// Whether this context has no authenticated subject.
96    ///
97    /// Anonymity is encoded as a nil `subject_id` / `subject_tenant_id`, the
98    /// same fields a real subject uses, so there is nothing on the type that
99    /// separates the two. Every consumer that cared was re-deriving this by
100    /// hand — `ctx.subject_id().is_nil() || ctx.subject_tenant_id().is_nil()`,
101    /// written out identically in the ledger and pricing gears — and a caller
102    /// that forgets the check treats an unauthenticated request as a subject in
103    /// the nil tenant.
104    #[must_use]
105    pub fn is_anonymous(&self) -> bool {
106        self.subject_id.is_nil() || self.subject_tenant_id.is_nil()
107    }
108
109    /// Whether the token carries `scope`.
110    ///
111    /// The wildcard `"*"` satisfies every scope: it is what a first-party
112    /// caller presents. An empty list satisfies none — it means no capability
113    /// was granted, which is why this is the accessor to reason with rather
114    /// than the raw list, where "empty" has repeatedly been read as its
115    /// opposite.
116    #[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    /// Get the original bearer token (for PDP forwarding).
124    #[must_use]
125    pub fn bearer_token(&self) -> Option<&SecretString> {
126        self.bearer_token.as_ref()
127    }
128}
129
130/// Builds a [`SecurityContext`] field by field.
131///
132/// `subject_id` and `subject_tenant_id` are required; [`Self::build`] reports a
133/// missing one rather than defaulting it, since a nil subject is how an
134/// *anonymous* context is represented and silently producing one would turn a
135/// wiring mistake into an unauthenticated caller. Use
136/// [`SecurityContext::anonymous`] when that is what you actually mean.
137///
138/// `Debug` never renders the bearer token: the field holds a `SecretString`,
139/// which redacts itself.
140#[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    /// Set the subject's unique id. Required.
151    #[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    /// Set the subject's classification, e.g. `"user"` or `"service"`.
158    ///
159    /// Optional to build, but it is the positive marker a real `AuthN` resolver
160    /// always populates, so consumers use its absence to spot a context that
161    /// never went through authentication.
162    #[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    /// Set the subject's home tenant. Required.
169    #[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    /// Set the token's capability scopes.
176    ///
177    /// `["*"]` is first-party / unrestricted. See the field documentation on
178    /// [`SecurityContext`] for what an empty list means.
179    #[must_use]
180    pub fn token_scopes(mut self, scopes: Vec<String>) -> Self {
181        self.token_scopes = scopes;
182        self
183    }
184
185    /// Carry the original bearer token, for forwarding to a policy decision
186    /// point. Never serialized and never rendered by `Debug`.
187    #[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    /// Build the `SecurityContext`.
194    ///
195    /// # Errors
196    ///
197    /// Returns `SecurityContextBuildError` if `subject_id` or
198    /// `subject_tenant_id` was not set. Use `SecurityContext::anonymous()`
199    /// for contexts that intentionally have no authenticated subject.
200    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        // `test_security_context_builder_full` already covers a plain chain
294        // with more assertions than this did. What nothing covered is a setter
295        // called twice: a builder that accumulated instead of replacing would
296        // pass every other test here.
297        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        // bearer_token is skipped during serialization
363        assert!(deserialized.bearer_token().is_none());
364    }
365
366    #[test]
367    fn test_security_context_bearer_token_not_serialized() {
368        // Built *with* a token: `anonymous()` has none, so asserting on it only
369        // checked that an absent value stays absent -- which keeps passing if
370        // `#[serde(skip)]` is replaced by anything that skips `None` but writes
371        // a real token, the exact case this guards.
372        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        // Either field being nil is enough: a subject with no tenant is not a
403        // subject this system can authorize.
404        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        // `anonymous()` is covered by `test_security_context_anonymous`; the
441        // case no other test reaches is a builder-supplied empty scope list.
442        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}