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