modkit_db/secure/select.rs
1use sea_orm::{
2 ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
3 Related, sea_query::Expr,
4};
5use std::sync::Arc;
6
7use crate::secure::cond::build_scope_condition;
8use crate::secure::error::ScopeError;
9use crate::secure::{AccessScope, DBRunner, DBRunnerInternal, ScopableEntity, SeaOrmRunner};
10
11/// Typestate marker: query has not yet been scoped.
12/// Cannot execute queries in this state.
13#[derive(Debug, Clone, Copy)]
14pub struct Unscoped;
15
16/// Typestate marker: query has been scoped with access control.
17/// Can now execute queries safely.
18///
19/// This marker carries the `AccessScope` internally so that related-entity
20/// queries can automatically apply the same scope without requiring it
21/// to be passed again.
22#[derive(Debug, Clone)]
23pub struct Scoped {
24 scope: Arc<AccessScope>,
25}
26
27/// A type-safe wrapper around `SeaORM`'s `Select` that enforces scoping.
28///
29/// This wrapper uses the typestate pattern to ensure that queries cannot
30/// be executed without first applying access control via `.scope_with()`.
31///
32/// When scoped (`SecureSelect<E, Scoped>`), the query carries the `AccessScope`
33/// internally. This allows related-entity queries (`find_also_related`,
34/// `find_with_related`) to automatically apply the same scope to related
35/// entities without requiring the scope to be passed again.
36///
37/// # Type Parameters
38/// - `E`: The `SeaORM` entity type
39/// - `S`: The typestate (`Unscoped` or `Scoped`)
40///
41/// # Example
42/// ```rust,ignore
43/// use modkit_db::secure::{AccessScope, SecureEntityExt};
44///
45/// let scope = AccessScope::for_tenants(vec![tenant_id]);
46/// let users = user::Entity::find()
47/// .secure() // Returns SecureSelect<E, Unscoped>
48/// .scope_with(&scope) // Returns SecureSelect<E, Scoped>
49/// .all(conn) // Now can execute
50/// .await?;
51///
52/// // Related queries auto-apply scope:
53/// let orders_with_items = Order::find()
54/// .secure()
55/// .scope_with(&scope)
56/// .find_with_related(line_item::Entity) // scope auto-applied to LineItem
57/// .all(conn)
58/// .await?;
59/// ```
60#[must_use]
61#[derive(Clone, Debug)]
62pub struct SecureSelect<E: EntityTrait, S> {
63 pub(crate) inner: sea_orm::Select<E>,
64 pub(crate) state: S,
65}
66
67/// A type-safe wrapper around `SeaORM`'s `SelectTwo` that enforces scoping.
68///
69/// This wrapper is used for `find_also_related` queries where you want to fetch
70/// an entity along with an optional related entity (1-to-0..1 relationship).
71///
72/// The wrapper carries the `AccessScope` internally so further chained operations
73/// can apply scoping consistently.
74///
75/// # Type Parameters
76/// - `E`: The primary `SeaORM` entity type
77/// - `F`: The related `SeaORM` entity type
78/// - `S`: The typestate (`Scoped` - note: only Scoped state is supported)
79///
80/// # Example
81/// ```rust,ignore
82/// use modkit_db::secure::{AccessScope, SecureEntityExt};
83///
84/// let scope = AccessScope::for_tenants(vec![tenant_id]);
85/// let rows: Vec<(fruit::Model, Option<cake::Model>)> = Fruit::find()
86/// .secure()
87/// .scope_with(&scope)
88/// .find_also_related(cake::Entity) // scope auto-applied to cake
89/// .all(db)
90/// .await?;
91/// ```
92#[must_use]
93#[derive(Clone, Debug)]
94pub struct SecureSelectTwo<E: EntityTrait, F: EntityTrait, S> {
95 pub(crate) inner: sea_orm::SelectTwo<E, F>,
96 pub(crate) state: S,
97}
98
99/// A type-safe wrapper around `SeaORM`'s `SelectTwoMany` that enforces scoping.
100///
101/// This wrapper is used for `find_with_related` queries where you want to fetch
102/// an entity along with all its related entities (1-to-many relationship).
103///
104/// The wrapper carries the `AccessScope` internally so further chained operations
105/// can apply scoping consistently.
106///
107/// # Type Parameters
108/// - `E`: The primary `SeaORM` entity type
109/// - `F`: The related `SeaORM` entity type
110/// - `S`: The typestate (`Scoped` - note: only Scoped state is supported)
111///
112/// # Example
113/// ```rust,ignore
114/// use modkit_db::secure::{AccessScope, SecureEntityExt};
115///
116/// let scope = AccessScope::for_tenants(vec![tenant_id]);
117/// let rows: Vec<(cake::Model, Vec<fruit::Model>)> = Cake::find()
118/// .secure()
119/// .scope_with(&scope)
120/// .find_with_related(fruit::Entity) // scope auto-applied to fruit
121/// .all(db)
122/// .await?;
123/// ```
124#[must_use]
125#[derive(Clone, Debug)]
126pub struct SecureSelectTwoMany<E: EntityTrait, F: EntityTrait, S> {
127 pub(crate) inner: sea_orm::SelectTwoMany<E, F>,
128 pub(crate) state: S,
129}
130
131/// Extension trait to convert a regular `SeaORM` `Select` into a `SecureSelect`.
132pub trait SecureEntityExt<E: EntityTrait>: Sized {
133 /// Convert this select query into a secure (unscoped) select.
134 /// You must call `.scope_with()` before executing the query.
135 fn secure(self) -> SecureSelect<E, Unscoped>;
136}
137
138impl<E> SecureEntityExt<E> for sea_orm::Select<E>
139where
140 E: EntityTrait,
141{
142 fn secure(self) -> SecureSelect<E, Unscoped> {
143 SecureSelect {
144 inner: self,
145 state: Unscoped,
146 }
147 }
148}
149
150// Methods available only on Unscoped queries
151impl<E> SecureSelect<E, Unscoped>
152where
153 E: ScopableEntity + EntityTrait,
154 E::Column: ColumnTrait + Copy,
155{
156 /// Apply access control scope to this query, transitioning to the `Scoped` state.
157 ///
158 /// The scope is stored internally and will be automatically applied to any
159 /// related-entity queries (e.g., `find_also_related`, `find_with_related`).
160 ///
161 /// This applies the implicit policy:
162 /// - Empty scope → deny all
163 /// - Tenants only → filter by tenant
164 /// - Resources only → filter by resource IDs
165 /// - Both → AND them together
166 ///
167 pub fn scope_with(self, scope: &AccessScope) -> SecureSelect<E, Scoped> {
168 let cond = build_scope_condition::<E>(scope);
169 SecureSelect {
170 inner: self.inner.filter(cond),
171 state: Scoped {
172 scope: Arc::new(scope.clone()),
173 },
174 }
175 }
176
177 /// Apply access control scope using an `Arc<AccessScope>`.
178 ///
179 /// This is useful when you already have the scope in an `Arc` and want to
180 /// avoid an extra clone.
181 pub fn scope_with_arc(self, scope: Arc<AccessScope>) -> SecureSelect<E, Scoped> {
182 let cond = build_scope_condition::<E>(&scope);
183 SecureSelect {
184 inner: self.inner.filter(cond),
185 state: Scoped { scope },
186 }
187 }
188}
189
190// Methods available only on Scoped queries
191impl<E> SecureSelect<E, Scoped>
192where
193 E: EntityTrait,
194{
195 /// Execute the query and return all matching results.
196 ///
197 /// # Errors
198 /// Returns `ScopeError::Db` if the database query fails.
199 #[allow(clippy::disallowed_methods)]
200 pub async fn all(self, runner: &impl DBRunner) -> Result<Vec<E::Model>, ScopeError> {
201 match DBRunnerInternal::as_seaorm(runner) {
202 SeaOrmRunner::Conn(db) => Ok(self.inner.all(db).await?),
203 SeaOrmRunner::Tx(tx) => Ok(self.inner.all(tx).await?),
204 }
205 }
206
207 /// Execute the query and return at most one result.
208 ///
209 /// # Errors
210 /// Returns `ScopeError::Db` if the database query fails.
211 #[allow(clippy::disallowed_methods)]
212 pub async fn one(self, runner: &impl DBRunner) -> Result<Option<E::Model>, ScopeError> {
213 match DBRunnerInternal::as_seaorm(runner) {
214 SeaOrmRunner::Conn(db) => Ok(self.inner.one(db).await?),
215 SeaOrmRunner::Tx(tx) => Ok(self.inner.one(tx).await?),
216 }
217 }
218
219 /// Execute the query and return the number of matching results.
220 ///
221 /// # Errors
222 /// Returns `ScopeError::Db` if the database query fails.
223 #[allow(clippy::disallowed_methods)]
224 pub async fn count(self, runner: &impl DBRunner) -> Result<u64, ScopeError>
225 where
226 E::Model: sea_orm::FromQueryResult + Send + Sync,
227 {
228 match DBRunnerInternal::as_seaorm(runner) {
229 SeaOrmRunner::Conn(db) => Ok(self.inner.count(db).await?),
230 SeaOrmRunner::Tx(tx) => Ok(self.inner.count(tx).await?),
231 }
232 }
233
234 // Note: count() uses SeaORM's `PaginatorTrait::count` internally.
235
236 // Note: For pagination, use `into_inner().paginate()` due to complex lifetime bounds
237
238 /// Add an additional filter for a specific resource ID.
239 ///
240 /// This is useful when you want to further narrow a scoped query
241 /// to a single resource.
242 ///
243 /// # Example
244 /// ```ignore
245 /// let user = User::find()
246 /// .secure()
247 /// .scope_with(&scope)?
248 /// .and_id(user_id)
249 /// .one(conn)
250 /// .await?;
251 /// ```
252 ///
253 /// # Errors
254 /// Returns `ScopeError::Invalid` if the entity doesn't have a resource column.
255 pub fn and_id(self, id: uuid::Uuid) -> Result<Self, ScopeError>
256 where
257 E: ScopableEntity,
258 E::Column: ColumnTrait + Copy,
259 {
260 let resource_col = E::resource_col().ok_or(ScopeError::Invalid(
261 "Entity must have a resource_col to use and_id()",
262 ))?;
263 let cond = sea_orm::Condition::all().add(Expr::col(resource_col).eq(id));
264 Ok(self.filter(cond))
265 }
266}
267
268// Allow further chaining on Scoped queries before execution
269impl<E> SecureSelect<E, Scoped>
270where
271 E: EntityTrait,
272{
273 /// Add additional filters to the scoped query.
274 /// The scope conditions remain in place.
275 pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
276 self.inner = QueryFilter::filter(self.inner, filter);
277 self
278 }
279
280 /// Add ordering to the scoped query.
281 pub fn order_by<C>(mut self, col: C, order: sea_orm::Order) -> Self
282 where
283 C: sea_orm::IntoSimpleExpr,
284 {
285 self.inner = QueryOrder::order_by(self.inner, col, order);
286 self
287 }
288
289 /// Add a limit to the scoped query.
290 pub fn limit(mut self, limit: u64) -> Self {
291 self.inner = QuerySelect::limit(self.inner, limit);
292 self
293 }
294
295 /// Add an offset to the scoped query.
296 pub fn offset(mut self, offset: u64) -> Self {
297 self.inner = QuerySelect::offset(self.inner, offset);
298 self
299 }
300
301 /// Apply scoping for a joined entity.
302 ///
303 /// This delegates to `build_scope_condition::<J>()` which handles all
304 /// property types (tenant, resource, owner, custom PEP properties) with
305 /// proper OR/AND constraint semantics.
306 ///
307 /// # Example
308 /// ```ignore
309 /// // Select orders, ensuring both Order and Customer match scope
310 /// Order::find()
311 /// .secure()
312 /// .scope_with(&scope)?
313 /// .and_scope_for::<customer::Entity>(&scope)
314 /// .all(conn)
315 /// .await?
316 /// ```
317 pub fn and_scope_for<J>(mut self, scope: &AccessScope) -> Self
318 where
319 J: ScopableEntity + EntityTrait,
320 J::Column: ColumnTrait + Copy,
321 {
322 let cond = build_scope_condition::<J>(scope);
323 self.inner = QueryFilter::filter(self.inner, cond);
324 self
325 }
326
327 /// Apply scoping via EXISTS subquery on a related entity.
328 ///
329 /// This is particularly useful when the base entity doesn't have a tenant column
330 /// but is related to one that does.
331 ///
332 /// This delegates to `build_scope_condition::<J>()` for the EXISTS subquery,
333 /// handling all property types with proper OR/AND constraint semantics.
334 ///
335 /// # Note
336 /// This is a simplified EXISTS check (no join predicate linking back to the
337 /// primary entity). For complex join predicates, use `into_inner()` and build
338 /// custom EXISTS clauses.
339 ///
340 /// # Example
341 /// ```ignore
342 /// // Find settings that exist in a scoped relationship
343 /// GlobalSetting::find()
344 /// .secure()
345 /// .scope_with(&AccessScope::for_resources(vec![]))?
346 /// .scope_via_exists::<TenantSetting>(&scope)
347 /// .all(conn)
348 /// .await?
349 /// ```
350 pub fn scope_via_exists<J>(mut self, scope: &AccessScope) -> Self
351 where
352 J: ScopableEntity + EntityTrait,
353 J::Column: ColumnTrait + Copy,
354 {
355 use sea_orm::sea_query::Query;
356
357 let cond = build_scope_condition::<J>(scope);
358
359 let mut sub = Query::select();
360 sub.expr(Expr::value(1)).from(J::default()).cond_where(cond);
361
362 self.inner =
363 QueryFilter::filter(self.inner, sea_orm::Condition::all().add(Expr::exists(sub)));
364 self
365 }
366
367 /// Unwrap the inner `SeaORM` `Select` for advanced use cases.
368 ///
369 /// # Safety
370 /// The caller must ensure they don't remove or bypass the security
371 /// conditions that were applied during `.scope_with()`.
372 #[must_use]
373 pub fn into_inner(self) -> sea_orm::Select<E> {
374 self.inner
375 }
376}
377
378// =============================================================================
379// Relationship Query Methods on SecureSelect<E, Scoped>
380// =============================================================================
381
382/// Build scope condition for a related entity, returning `None` for unrestricted entities.
383///
384/// Delegates to `build_scope_condition::<R>()` which handles all property types
385/// (tenant, resource, owner, custom PEP properties) with proper OR/AND semantics.
386///
387/// Returns `None` when the scope is unconstrained (allow-all), so the caller
388/// can skip adding a no-op filter.
389fn apply_related_scope<R>(scope: &AccessScope) -> Option<sea_orm::Condition>
390where
391 R: ScopableEntity + EntityTrait,
392 R::Column: ColumnTrait + Copy,
393{
394 if scope.is_unconstrained() {
395 return None;
396 }
397 Some(build_scope_condition::<R>(scope))
398}
399
400impl<E> SecureSelect<E, Scoped>
401where
402 E: EntityTrait,
403{
404 /// Get a reference to the stored scope.
405 ///
406 /// This is useful when you need to pass the scope to other secure operations.
407 #[must_use]
408 pub fn scope(&self) -> &AccessScope {
409 &self.state.scope
410 }
411
412 /// Get the stored scope as an `Arc`.
413 ///
414 /// This is useful when you need to share the scope without cloning.
415 #[must_use]
416 pub fn scope_arc(&self) -> Arc<AccessScope> {
417 Arc::clone(&self.state.scope)
418 }
419
420 /// Find related entities using `find_also_related` with automatic scoping.
421 ///
422 /// This executes a LEFT JOIN to fetch the primary entity along with an
423 /// optional related entity. The related entity will be `None` if no
424 /// matching row exists.
425 ///
426 /// # Automatic Scoping
427 /// - The primary entity `E` is already scoped by the parent `SecureSelect`.
428 /// - The related entity `R` will automatically have tenant filtering applied
429 /// **if it has a tenant column** (i.e., `R::tenant_col()` returns `Some`).
430 /// - For **global entities** (those with `#[secure(no_tenant)]`), no additional
431 /// filtering is applied — the scoping becomes a no-op automatically.
432 ///
433 /// This unified API handles both tenant-scoped and global entities transparently.
434 /// The caller does not need to know or care whether the related entity is
435 /// tenant-scoped or global.
436 ///
437 /// # Entity Requirements
438 /// All entities used with this method must derive `Scopable`. For global entities,
439 /// use `#[secure(no_tenant, no_resource, no_owner, no_type)]`.
440 ///
441 /// # Example
442 /// ```rust,ignore
443 /// let scope = AccessScope::for_tenants(vec![tenant_id]);
444 ///
445 /// // Tenant-scoped related entity - scope is auto-applied to Customer
446 /// let rows: Vec<(order::Model, Option<customer::Model>)> = Order::find()
447 /// .secure()
448 /// .scope_with(&scope)
449 /// .find_also_related(customer::Entity)
450 /// .all(db)
451 /// .await?;
452 ///
453 /// // Global related entity (no tenant column) - no filtering applied to GlobalConfig
454 /// let rows: Vec<(order::Model, Option<global_config::Model>)> = Order::find()
455 /// .secure()
456 /// .scope_with(&scope)
457 /// .find_also_related(global_config::Entity) // same API, auto no-op!
458 /// .all(db)
459 /// .await?;
460 /// ```
461 pub fn find_also_related<R>(self, r: R) -> SecureSelectTwo<E, R, Scoped>
462 where
463 R: ScopableEntity + EntityTrait,
464 R::Column: ColumnTrait + Copy,
465 E: Related<R>,
466 {
467 let select_two = self.inner.find_also_related(r);
468
469 // Auto-apply scope to the related entity R (no-op if R has no tenant_col)
470 let select_two = if let Some(cond) = apply_related_scope::<R>(&self.state.scope) {
471 QueryFilter::filter(select_two, cond)
472 } else {
473 select_two
474 };
475
476 SecureSelectTwo {
477 inner: select_two,
478 state: self.state,
479 }
480 }
481
482 /// Find all related entities using `find_with_related` with automatic scoping.
483 ///
484 /// This executes a query to fetch the primary entity along with all its
485 /// related entities (one-to-many relationship).
486 ///
487 /// # Automatic Scoping
488 /// - The primary entity `E` is already scoped by the parent `SecureSelect`.
489 /// - The related entity `R` will automatically have tenant filtering applied
490 /// **if it has a tenant column** (i.e., `R::tenant_col()` returns `Some`).
491 /// - For **global entities** (those with `#[secure(no_tenant)]`), no additional
492 /// filtering is applied — the scoping becomes a no-op automatically.
493 ///
494 /// This unified API handles both tenant-scoped and global entities transparently.
495 /// The caller does not need to know or care whether the related entity is
496 /// tenant-scoped or global.
497 ///
498 /// # Entity Requirements
499 /// All entities used with this method must derive `Scopable`. For global entities,
500 /// use `#[secure(no_tenant, no_resource, no_owner, no_type)]`.
501 ///
502 /// # Example
503 /// ```rust,ignore
504 /// let scope = AccessScope::for_tenants(vec![tenant_id]);
505 ///
506 /// // Tenant-scoped related entity - scope is auto-applied to LineItem
507 /// let rows: Vec<(order::Model, Vec<line_item::Model>)> = Order::find()
508 /// .secure()
509 /// .scope_with(&scope)
510 /// .find_with_related(line_item::Entity)
511 /// .all(db)
512 /// .await?;
513 ///
514 /// // Global related entity (no tenant column) - no filtering applied to SystemTag
515 /// let rows: Vec<(order::Model, Vec<system_tag::Model>)> = Order::find()
516 /// .secure()
517 /// .scope_with(&scope)
518 /// .find_with_related(system_tag::Entity) // same API, auto no-op!
519 /// .all(db)
520 /// .await?;
521 /// ```
522 pub fn find_with_related<R>(self, r: R) -> SecureSelectTwoMany<E, R, Scoped>
523 where
524 R: ScopableEntity + EntityTrait,
525 R::Column: ColumnTrait + Copy,
526 E: Related<R>,
527 {
528 let select_two_many = self.inner.find_with_related(r);
529
530 // Auto-apply scope to the related entity R (no-op if R has no tenant_col)
531 let select_two_many = if let Some(cond) = apply_related_scope::<R>(&self.state.scope) {
532 QueryFilter::filter(select_two_many, cond)
533 } else {
534 select_two_many
535 };
536
537 SecureSelectTwoMany {
538 inner: select_two_many,
539 state: self.state,
540 }
541 }
542}
543
544// =============================================================================
545// SecureSelectTwo<E, F, Scoped> - Execution methods
546// =============================================================================
547
548impl<E, F> SecureSelectTwo<E, F, Scoped>
549where
550 E: EntityTrait,
551 F: EntityTrait,
552{
553 /// Get a reference to the stored scope.
554 #[must_use]
555 pub fn scope(&self) -> &AccessScope {
556 &self.state.scope
557 }
558
559 /// Get the stored scope as an `Arc`.
560 #[must_use]
561 pub fn scope_arc(&self) -> Arc<AccessScope> {
562 Arc::clone(&self.state.scope)
563 }
564
565 /// Execute the query and return all matching results.
566 ///
567 /// Returns pairs of `(E::Model, Option<F::Model>)`.
568 ///
569 /// # Errors
570 /// Returns `ScopeError::Db` if the database query fails.
571 #[allow(clippy::disallowed_methods)]
572 pub async fn all(
573 self,
574 runner: &impl DBRunner,
575 ) -> Result<Vec<(E::Model, Option<F::Model>)>, ScopeError> {
576 match DBRunnerInternal::as_seaorm(runner) {
577 SeaOrmRunner::Conn(db) => Ok(self.inner.all(db).await?),
578 SeaOrmRunner::Tx(tx) => Ok(self.inner.all(tx).await?),
579 }
580 }
581
582 /// Execute the query and return at most one result.
583 ///
584 /// # Errors
585 /// Returns `ScopeError::Db` if the database query fails.
586 #[allow(clippy::disallowed_methods)]
587 pub async fn one(
588 self,
589 runner: &impl DBRunner,
590 ) -> Result<Option<(E::Model, Option<F::Model>)>, ScopeError> {
591 match DBRunnerInternal::as_seaorm(runner) {
592 SeaOrmRunner::Conn(db) => Ok(self.inner.one(db).await?),
593 SeaOrmRunner::Tx(tx) => Ok(self.inner.one(tx).await?),
594 }
595 }
596
597 /// Add additional filters to the query.
598 pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
599 self.inner = QueryFilter::filter(self.inner, filter);
600 self
601 }
602
603 /// Add ordering to the query.
604 pub fn order_by<C>(mut self, col: C, order: sea_orm::Order) -> Self
605 where
606 C: sea_orm::IntoSimpleExpr,
607 {
608 self.inner = QueryOrder::order_by(self.inner, col, order);
609 self
610 }
611
612 /// Add a limit to the query.
613 pub fn limit(mut self, limit: u64) -> Self {
614 self.inner = QuerySelect::limit(self.inner, limit);
615 self
616 }
617
618 /// Unwrap the inner `SeaORM` `SelectTwo` for advanced use cases.
619 #[must_use]
620 pub fn into_inner(self) -> sea_orm::SelectTwo<E, F> {
621 self.inner
622 }
623}
624
625// =============================================================================
626// SecureSelectTwoMany<E, F, Scoped> - Execution methods
627// =============================================================================
628
629impl<E, F> SecureSelectTwoMany<E, F, Scoped>
630where
631 E: EntityTrait,
632 F: EntityTrait,
633{
634 /// Get a reference to the stored scope.
635 #[must_use]
636 pub fn scope(&self) -> &AccessScope {
637 &self.state.scope
638 }
639
640 /// Get the stored scope as an `Arc`.
641 #[must_use]
642 pub fn scope_arc(&self) -> Arc<AccessScope> {
643 Arc::clone(&self.state.scope)
644 }
645
646 /// Execute the query and return all matching results.
647 ///
648 /// Returns pairs of `(E::Model, Vec<F::Model>)`.
649 ///
650 /// # Errors
651 /// Returns `ScopeError::Db` if the database query fails.
652 #[allow(clippy::disallowed_methods)]
653 pub async fn all(
654 self,
655 runner: &impl DBRunner,
656 ) -> Result<Vec<(E::Model, Vec<F::Model>)>, ScopeError> {
657 match DBRunnerInternal::as_seaorm(runner) {
658 SeaOrmRunner::Conn(db) => Ok(self.inner.all(db).await?),
659 SeaOrmRunner::Tx(tx) => Ok(self.inner.all(tx).await?),
660 }
661 }
662
663 /// Add additional filters to the query.
664 pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
665 self.inner = QueryFilter::filter(self.inner, filter);
666 self
667 }
668
669 /// Add ordering to the query.
670 pub fn order_by<C>(mut self, col: C, order: sea_orm::Order) -> Self
671 where
672 C: sea_orm::IntoSimpleExpr,
673 {
674 self.inner = QueryOrder::order_by(self.inner, col, order);
675 self
676 }
677
678 /// Unwrap the inner `SeaORM` `SelectTwoMany` for advanced use cases.
679 #[must_use]
680 pub fn into_inner(self) -> sea_orm::SelectTwoMany<E, F> {
681 self.inner
682 }
683}
684
685// =============================================================================
686// Model-level find_related Extension Trait
687// =============================================================================
688
689/// Extension trait to perform secure `find_related` queries from a model instance.
690///
691/// This trait provides a way to find entities related to an already-loaded model
692/// while maintaining security scope constraints.
693///
694/// # Example
695/// ```rust,ignore
696/// use modkit_db::secure::{AccessScope, SecureFindRelatedExt};
697///
698/// // Load a cake
699/// let cake: cake::Model = db.find_by_id::<cake::Entity>(&scope, cake_id)?
700/// .one(db)
701/// .await?
702/// .unwrap();
703///
704/// // Find all related fruits with scoping
705/// let fruits: Vec<fruit::Model> = cake
706/// .secure_find_related(fruit::Entity, &scope)
707/// .all(db)
708/// .await?;
709/// ```
710pub trait SecureFindRelatedExt: ModelTrait {
711 /// Find related entities with access scope applied.
712 ///
713 /// This creates a scoped query for entities related to this model.
714 /// The scope is applied to the related entity to ensure tenant isolation.
715 ///
716 /// # Type Parameters
717 /// - `R`: The related entity type that must implement `ScopableEntity`
718 ///
719 /// # Arguments
720 /// - `r`: The related entity marker (e.g., `fruit::Entity`)
721 /// - `scope`: The access scope to apply to the related entity query
722 fn secure_find_related<R>(&self, r: R, scope: &AccessScope) -> SecureSelect<R, Scoped>
723 where
724 R: ScopableEntity + EntityTrait,
725 R::Column: ColumnTrait + Copy,
726 Self::Entity: Related<R>;
727}
728
729impl<M> SecureFindRelatedExt for M
730where
731 M: ModelTrait,
732{
733 fn secure_find_related<R>(&self, r: R, scope: &AccessScope) -> SecureSelect<R, Scoped>
734 where
735 R: ScopableEntity + EntityTrait,
736 R::Column: ColumnTrait + Copy,
737 Self::Entity: Related<R>,
738 {
739 // Use SeaORM's find_related to build the base query
740 let select = self.find_related(r);
741
742 // Apply scope to the related entity
743 select.secure().scope_with(scope)
744 }
745}
746
747#[cfg(test)]
748#[cfg_attr(coverage_nightly, coverage(off))]
749mod tests {
750 use super::*;
751 use modkit_security::pep_properties;
752
753 // Note: Full integration tests with real SeaORM entities should be written
754 // in application code where actual entities are available.
755 // The typestate pattern is enforced at compile time.
756 //
757 // See USAGE_EXAMPLE.md for complete usage patterns.
758
759 #[test]
760 fn test_typestate_markers_exist() {
761 // This test verifies the typestate markers compile
762 // The actual enforcement happens at compile time
763 let unscoped = Unscoped;
764 assert!(std::mem::size_of_val(&unscoped) == 0); // Unscoped is zero-sized
765
766 // Scoped now requires an AccessScope
767 let scope = AccessScope::default();
768 let scoped = Scoped {
769 scope: Arc::new(scope),
770 };
771 assert!(!scoped.scope.has_property(pep_properties::OWNER_TENANT_ID)); // default scope has no tenants
772 }
773
774 #[test]
775 fn test_scoped_state_holds_scope() {
776 let tenant_id = uuid::Uuid::new_v4();
777 let scope = AccessScope::for_tenants(vec![tenant_id]);
778 let scoped = Scoped {
779 scope: Arc::new(scope),
780 };
781
782 // Verify the scope is accessible
783 assert!(scoped.scope.has_property(pep_properties::OWNER_TENANT_ID));
784 assert_eq!(
785 scoped
786 .scope
787 .all_values_for(pep_properties::OWNER_TENANT_ID)
788 .len(),
789 1
790 );
791 assert!(
792 scoped
793 .scope
794 .all_uuid_values_for(pep_properties::OWNER_TENANT_ID)
795 .contains(&tenant_id)
796 );
797 }
798
799 #[test]
800 fn test_scoped_state_is_cloneable() {
801 let scope = AccessScope::for_tenants(vec![uuid::Uuid::new_v4()]);
802 let scoped = Scoped {
803 scope: Arc::new(scope),
804 };
805
806 // Cloning should share the Arc
807 let cloned = scoped.clone();
808 assert!(Arc::ptr_eq(&scoped.scope, &cloned.scope));
809 }
810}