Skip to main content

modkit_sdk/
secured.rs

1//! Security context scoping for clients
2//!
3//! This module provides a lightweight, zero-allocation wrapper that binds a `SecurityContext`
4//! to any client type, enabling security-aware API calls without cloning or Arc overhead.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use modkit_sdk::secured::{Secured, WithSecurityContext};
10//! use modkit_security::SecurityContext;
11//!
12//! let client = MyClient::new();
13//! let ctx = SecurityContext::builder()
14//!     .subject_id(TEST_SUBJECT_ID)
15//!     .subject_tenant_id(TEST_TENANT_ID)
16//!     .build()?;
17//!
18//! // Bind the security context to the client
19//! let secured = client.security_ctx(&ctx);
20//!
21//! // Access the client and context
22//! let client_ref = secured.client();
23//! let ctx_ref = secured.ctx();
24//! ```
25
26use modkit_security::SecurityContext;
27
28/// A wrapper that binds a `SecurityContext` to a client reference.
29///
30/// This struct provides a zero-cost abstraction for carrying both a client
31/// and its associated security context together, without any allocation or cloning.
32///
33/// # Type Parameters
34///
35/// * `'a` - The lifetime of both the client and security context references
36/// * `C` - The client type being wrapped
37#[derive(Debug)]
38pub struct Secured<'a, C> {
39    client: &'a C,
40    ctx: &'a SecurityContext,
41}
42
43impl<'a, C> Secured<'a, C> {
44    /// Creates a new `Secured` wrapper binding a client and security context.
45    ///
46    /// # Arguments
47    ///
48    /// * `client` - Reference to the client
49    /// * `ctx` - Reference to the security context
50    ///
51    /// # Example
52    ///
53    /// ```rust,ignore
54    /// let secured = Secured::new(&client, &ctx);
55    /// ```
56    #[must_use]
57    pub fn new(client: &'a C, ctx: &'a SecurityContext) -> Self {
58        Self { client, ctx }
59    }
60
61    /// Returns a reference to the wrapped client.
62    ///
63    /// # Example
64    ///
65    /// ```rust,ignore
66    /// let client_ref = secured.client();
67    /// ```
68    #[must_use]
69    pub fn client(&self) -> &'a C {
70        self.client
71    }
72
73    /// Returns a reference to the security context.
74    ///
75    /// # Example
76    ///
77    /// ```rust,ignore
78    /// let ctx_ref = secured.ctx();
79    /// let tenant_id = ctx_ref.subject_tenant_id();
80    /// ```
81    #[must_use]
82    pub fn ctx(&self) -> &'a SecurityContext {
83        self.ctx
84    }
85
86    /// Create a new query builder for the given schema.
87    ///
88    /// This provides an ergonomic entrypoint for building queries from a secured client.
89    ///
90    /// # Example
91    ///
92    /// ```rust,ignore
93    /// use modkit_sdk::odata::items_stream;
94    ///
95    /// let items = items_stream(
96    ///     client.security_ctx(&ctx)
97    ///         .query::<UserSchema>()
98    ///         .filter(user::email().contains("@example.com")),
99    ///     |query| async move { client.list_users(query).await },
100    /// );
101    /// ```
102    #[must_use]
103    pub fn query<S: crate::odata::Schema>(&self) -> crate::odata::QueryBuilder<S> {
104        crate::odata::QueryBuilder::new()
105    }
106}
107
108impl<C> Clone for Secured<'_, C> {
109    fn clone(&self) -> Self {
110        *self
111    }
112}
113
114impl<C> Copy for Secured<'_, C> {}
115
116/// Extension trait that adds the `security_ctx` method to any type.
117///
118/// This trait enables any client to be wrapped with a security context
119/// using a fluent API: `client.security_ctx(&ctx)`.
120///
121/// # Example
122///
123/// ```rust,ignore
124/// use modkit_sdk::secured::WithSecurityContext;
125///
126/// let secured = my_client.security_ctx(&security_context);
127/// ```
128pub trait WithSecurityContext {
129    /// Binds a security context to this client, returning a `Secured` wrapper.
130    ///
131    /// # Arguments
132    ///
133    /// * `ctx` - Reference to the security context to bind
134    ///
135    /// # Returns
136    ///
137    /// A `Secured` wrapper containing references to both the client and context.
138    ///
139    /// # Example
140    ///
141    /// ```rust,ignore
142    /// let secured = client.security_ctx(&ctx);
143    /// assert_eq!(secured.ctx().subject_tenant_id(), ctx.subject_tenant_id());
144    /// ```
145    fn security_ctx<'a>(&'a self, ctx: &'a SecurityContext) -> Secured<'a, Self>
146    where
147        Self: Sized;
148}
149
150impl<T> WithSecurityContext for T {
151    fn security_ctx<'a>(&'a self, ctx: &'a SecurityContext) -> Secured<'a, Self>
152    where
153        Self: Sized,
154    {
155        Secured::new(self, ctx)
156    }
157}
158
159#[cfg(test)]
160#[cfg_attr(coverage_nightly, coverage(off))]
161mod tests {
162    use super::*;
163    use uuid::{Uuid, uuid};
164
165    /// Test tenant ID for unit tests.
166    const TEST_TENANT_ID: Uuid = uuid!("00000000-0000-0000-0000-000000000001");
167    /// Test subject ID for unit tests.
168    const TEST_SUBJECT_ID: Uuid = uuid!("00000000-0000-0000-0000-000000000002");
169
170    fn test_ctx() -> SecurityContext {
171        SecurityContext::builder()
172            .subject_id(TEST_SUBJECT_ID)
173            .subject_tenant_id(TEST_TENANT_ID)
174            .build()
175            .unwrap()
176    }
177
178    struct MockClient {
179        name: String,
180    }
181
182    impl MockClient {
183        fn new(name: &str) -> Self {
184            Self {
185                name: name.to_owned(),
186            }
187        }
188
189        fn get_name(&self) -> &str {
190            &self.name
191        }
192    }
193
194    #[test]
195    fn test_secured_new() {
196        let client = MockClient::new("test-client");
197        let ctx = test_ctx();
198
199        let secured = Secured::new(&client, &ctx);
200
201        assert_eq!(secured.client().get_name(), "test-client");
202        assert_eq!(secured.ctx().subject_tenant_id(), ctx.subject_tenant_id());
203    }
204
205    #[test]
206    fn test_secured_getters() {
207        let client = MockClient::new("test-client");
208        let ctx = test_ctx();
209
210        let secured = Secured::new(&client, &ctx);
211
212        let client_ref = secured.client();
213        assert_eq!(client_ref.get_name(), "test-client");
214
215        let ctx_ref = secured.ctx();
216        assert_eq!(ctx_ref.subject_tenant_id(), TEST_TENANT_ID);
217    }
218
219    #[test]
220    fn test_with_security_context_trait() {
221        let client = MockClient::new("test-client");
222        let ctx = test_ctx();
223
224        let secured = client.security_ctx(&ctx);
225
226        assert_eq!(secured.client().get_name(), "test-client");
227        assert_eq!(secured.ctx().subject_tenant_id(), ctx.subject_tenant_id());
228    }
229
230    #[test]
231    fn test_secured_clone() {
232        let client = MockClient::new("test-client");
233        let ctx = test_ctx();
234
235        let secured1 = client.security_ctx(&ctx);
236        let secured2 = secured1;
237
238        assert_eq!(secured1.client().get_name(), secured2.client().get_name());
239        assert_eq!(
240            secured1.ctx().subject_tenant_id(),
241            secured2.ctx().subject_tenant_id()
242        );
243    }
244
245    #[test]
246    fn test_secured_copy() {
247        let client = MockClient::new("test-client");
248        let ctx = test_ctx();
249
250        let secured1 = client.security_ctx(&ctx);
251        let secured2 = secured1;
252
253        assert_eq!(secured1.client().get_name(), secured2.client().get_name());
254        assert_eq!(
255            secured1.ctx().subject_tenant_id(),
256            secured2.ctx().subject_tenant_id()
257        );
258    }
259
260    #[test]
261    fn test_secured_with_anonymous_context() {
262        let client = MockClient::new("test-client");
263        let ctx = SecurityContext::anonymous();
264
265        let secured = client.security_ctx(&ctx);
266
267        assert_eq!(secured.client().get_name(), "test-client");
268        assert_eq!(secured.ctx().subject_tenant_id(), Uuid::default());
269        assert_eq!(secured.ctx().subject_id(), Uuid::default());
270    }
271
272    #[test]
273    fn test_secured_with_custom_context() {
274        let client = MockClient::new("test-client");
275        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
276        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
277
278        let ctx = SecurityContext::builder()
279            .subject_tenant_id(tenant_id)
280            .subject_id(subject_id)
281            .subject_type("user")
282            .build()
283            .unwrap();
284
285        let secured = client.security_ctx(&ctx);
286
287        assert_eq!(secured.ctx().subject_tenant_id(), tenant_id);
288        assert_eq!(secured.ctx().subject_id(), subject_id);
289    }
290
291    #[test]
292    fn test_secured_zero_allocation() {
293        let client = MockClient::new("test-client");
294        let ctx = test_ctx();
295
296        let secured = client.security_ctx(&ctx);
297
298        assert_eq!(
299            std::mem::size_of_val(&secured),
300            std::mem::size_of::<&MockClient>() + std::mem::size_of::<&SecurityContext>()
301        );
302    }
303
304    #[test]
305    fn test_multiple_clients_with_same_context() {
306        let client1 = MockClient::new("client-1");
307        let client2 = MockClient::new("client-2");
308        let ctx = test_ctx();
309
310        let secured1 = client1.security_ctx(&ctx);
311        let secured2 = client2.security_ctx(&ctx);
312
313        assert_eq!(secured1.client().get_name(), "client-1");
314        assert_eq!(secured2.client().get_name(), "client-2");
315        assert_eq!(
316            secured1.ctx().subject_tenant_id(),
317            secured2.ctx().subject_tenant_id()
318        );
319    }
320
321    #[test]
322    fn test_secured_preserves_lifetimes() {
323        let client = MockClient::new("test-client");
324        let ctx = test_ctx();
325
326        let secured = client.security_ctx(&ctx);
327
328        assert_eq!(secured.client().get_name(), "test-client");
329        assert_eq!(secured.ctx().subject_tenant_id(), ctx.subject_tenant_id());
330    }
331
332    #[test]
333    fn test_secured_query_builder() {
334        use crate::odata::Schema;
335
336        #[derive(Copy, Clone, Eq, PartialEq, Debug)]
337        enum TestField {
338            #[allow(dead_code)]
339            Name,
340        }
341
342        struct TestSchema;
343
344        impl Schema for TestSchema {
345            type Field = TestField;
346
347            fn field_name(field: Self::Field) -> &'static str {
348                match field {
349                    TestField::Name => "name",
350                }
351            }
352        }
353
354        let client = MockClient::new("test-client");
355        let ctx = test_ctx();
356
357        let secured = client.security_ctx(&ctx);
358        let query_builder = secured.query::<TestSchema>();
359
360        let query = query_builder.page_size(50).build();
361        assert_eq!(query.limit, Some(50));
362    }
363}