Skip to main content

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    /// Execute a custom projection on this scoped query and return all results.
368    ///
369    /// The closure receives the inner
370    /// `Select<E>` (already scoped) and must return a
371    /// `Selector<SelectModel<T>>` — typically built via `.select_only()`,
372    /// `.column()`, `.group_by()`, `.into_model::<T>()`, etc.
373    ///
374    /// Because the method consumes `SecureSelect<E, Scoped>`, the compiler
375    /// guarantees that scope was applied before the projection runs.
376    ///
377    /// # Example
378    /// ```rust,ignore
379    /// let counts: Vec<ChatCount> = MsgEntity::find()
380    ///     .filter(condition)
381    ///     .secure()
382    ///     .scope_with(&scope)
383    ///     .project_all(conn, |q| {
384    ///         q.select_only()
385    ///          .column(MsgColumn::ChatId)
386    ///          .column_as(Expr::col(MsgColumn::Id).count(), "cnt")
387    ///          .group_by(MsgColumn::ChatId)
388    ///          .into_model::<ChatCount>()
389    ///     })
390    ///     .await?;
391    /// ```
392    ///
393    /// # Errors
394    /// Returns `ScopeError::Db` if the database query fails.
395    #[allow(clippy::disallowed_methods)]
396    pub async fn project_all<T, C, F>(self, runner: &C, project: F) -> Result<Vec<T>, ScopeError>
397    where
398        T: sea_orm::FromQueryResult + Send + Sync,
399        C: DBRunner,
400        F: FnOnce(sea_orm::Select<E>) -> sea_orm::Selector<sea_orm::SelectModel<T>>,
401    {
402        let selector = project(self.inner);
403        match DBRunnerInternal::as_seaorm(runner) {
404            SeaOrmRunner::Conn(db) => Ok(selector.all(db).await?),
405            SeaOrmRunner::Tx(tx) => Ok(selector.all(tx).await?),
406        }
407    }
408
409    /// Unwrap the inner `SeaORM` `Select` for advanced use cases.
410    ///
411    /// Prefer [`project_all`](Self::project_all) for custom projections —
412    /// it preserves compile-time scope enforcement. Use `into_inner()` only
413    /// when the query must be passed to an API that requires a raw `Select<E>`
414    /// (e.g., `paginate_odata`).
415    #[must_use]
416    pub fn into_inner(self) -> sea_orm::Select<E> {
417        self.inner
418    }
419}
420
421// =============================================================================
422// Relationship Query Methods on SecureSelect<E, Scoped>
423// =============================================================================
424
425/// Build scope condition for a related entity, returning `None` for unrestricted entities.
426///
427/// Delegates to `build_scope_condition::<R>()` which handles all property types
428/// (tenant, resource, owner, custom PEP properties) with proper OR/AND semantics.
429///
430/// Returns `None` when the scope is unconstrained (allow-all), so the caller
431/// can skip adding a no-op filter.
432fn apply_related_scope<R>(scope: &AccessScope) -> Option<sea_orm::Condition>
433where
434    R: ScopableEntity + EntityTrait,
435    R::Column: ColumnTrait + Copy,
436{
437    if scope.is_unconstrained() {
438        return None;
439    }
440    Some(build_scope_condition::<R>(scope))
441}
442
443impl<E> SecureSelect<E, Scoped>
444where
445    E: EntityTrait,
446{
447    /// Get a reference to the stored scope.
448    ///
449    /// This is useful when you need to pass the scope to other secure operations.
450    #[must_use]
451    pub fn scope(&self) -> &AccessScope {
452        &self.state.scope
453    }
454
455    /// Get the stored scope as an `Arc`.
456    ///
457    /// This is useful when you need to share the scope without cloning.
458    #[must_use]
459    pub fn scope_arc(&self) -> Arc<AccessScope> {
460        Arc::clone(&self.state.scope)
461    }
462
463    /// Find related entities using `find_also_related` with automatic scoping.
464    ///
465    /// This executes a LEFT JOIN to fetch the primary entity along with an
466    /// optional related entity. The related entity will be `None` if no
467    /// matching row exists.
468    ///
469    /// # Automatic Scoping
470    /// - The primary entity `E` is already scoped by the parent `SecureSelect`.
471    /// - The related entity `R` will automatically have tenant filtering applied
472    ///   **if it has a tenant column** (i.e., `R::tenant_col()` returns `Some`).
473    /// - For **global entities** (those with `#[secure(no_tenant)]`), no additional
474    ///   filtering is applied — the scoping becomes a no-op automatically.
475    ///
476    /// This unified API handles both tenant-scoped and global entities transparently.
477    /// The caller does not need to know or care whether the related entity is
478    /// tenant-scoped or global.
479    ///
480    /// # Entity Requirements
481    /// All entities used with this method must derive `Scopable`. For global entities,
482    /// use `#[secure(no_tenant, no_resource, no_owner, no_type)]`.
483    ///
484    /// # Example
485    /// ```rust,ignore
486    /// let scope = AccessScope::for_tenants(vec![tenant_id]);
487    ///
488    /// // Tenant-scoped related entity - scope is auto-applied to Customer
489    /// let rows: Vec<(order::Model, Option<customer::Model>)> = Order::find()
490    ///     .secure()
491    ///     .scope_with(&scope)
492    ///     .find_also_related(customer::Entity)
493    ///     .all(db)
494    ///     .await?;
495    ///
496    /// // Global related entity (no tenant column) - no filtering applied to GlobalConfig
497    /// let rows: Vec<(order::Model, Option<global_config::Model>)> = Order::find()
498    ///     .secure()
499    ///     .scope_with(&scope)
500    ///     .find_also_related(global_config::Entity)  // same API, auto no-op!
501    ///     .all(db)
502    ///     .await?;
503    /// ```
504    pub fn find_also_related<R>(self, r: R) -> SecureSelectTwo<E, R, Scoped>
505    where
506        R: ScopableEntity + EntityTrait,
507        R::Column: ColumnTrait + Copy,
508        E: Related<R>,
509    {
510        let select_two = self.inner.find_also_related(r);
511
512        // Auto-apply scope to the related entity R (no-op if R has no tenant_col)
513        let select_two = if let Some(cond) = apply_related_scope::<R>(&self.state.scope) {
514            QueryFilter::filter(select_two, cond)
515        } else {
516            select_two
517        };
518
519        SecureSelectTwo {
520            inner: select_two,
521            state: self.state,
522        }
523    }
524
525    /// Find all related entities using `find_with_related` with automatic scoping.
526    ///
527    /// This executes a query to fetch the primary entity along with all its
528    /// related entities (one-to-many relationship).
529    ///
530    /// # Automatic Scoping
531    /// - The primary entity `E` is already scoped by the parent `SecureSelect`.
532    /// - The related entity `R` will automatically have tenant filtering applied
533    ///   **if it has a tenant column** (i.e., `R::tenant_col()` returns `Some`).
534    /// - For **global entities** (those with `#[secure(no_tenant)]`), no additional
535    ///   filtering is applied — the scoping becomes a no-op automatically.
536    ///
537    /// This unified API handles both tenant-scoped and global entities transparently.
538    /// The caller does not need to know or care whether the related entity is
539    /// tenant-scoped or global.
540    ///
541    /// # Entity Requirements
542    /// All entities used with this method must derive `Scopable`. For global entities,
543    /// use `#[secure(no_tenant, no_resource, no_owner, no_type)]`.
544    ///
545    /// # Example
546    /// ```rust,ignore
547    /// let scope = AccessScope::for_tenants(vec![tenant_id]);
548    ///
549    /// // Tenant-scoped related entity - scope is auto-applied to LineItem
550    /// let rows: Vec<(order::Model, Vec<line_item::Model>)> = Order::find()
551    ///     .secure()
552    ///     .scope_with(&scope)
553    ///     .find_with_related(line_item::Entity)
554    ///     .all(db)
555    ///     .await?;
556    ///
557    /// // Global related entity (no tenant column) - no filtering applied to SystemTag
558    /// let rows: Vec<(order::Model, Vec<system_tag::Model>)> = Order::find()
559    ///     .secure()
560    ///     .scope_with(&scope)
561    ///     .find_with_related(system_tag::Entity)  // same API, auto no-op!
562    ///     .all(db)
563    ///     .await?;
564    /// ```
565    pub fn find_with_related<R>(self, r: R) -> SecureSelectTwoMany<E, R, Scoped>
566    where
567        R: ScopableEntity + EntityTrait,
568        R::Column: ColumnTrait + Copy,
569        E: Related<R>,
570    {
571        let select_two_many = self.inner.find_with_related(r);
572
573        // Auto-apply scope to the related entity R (no-op if R has no tenant_col)
574        let select_two_many = if let Some(cond) = apply_related_scope::<R>(&self.state.scope) {
575            QueryFilter::filter(select_two_many, cond)
576        } else {
577            select_two_many
578        };
579
580        SecureSelectTwoMany {
581            inner: select_two_many,
582            state: self.state,
583        }
584    }
585}
586
587// =============================================================================
588// SecureSelectTwo<E, F, Scoped> - Execution methods
589// =============================================================================
590
591impl<E, F> SecureSelectTwo<E, F, Scoped>
592where
593    E: EntityTrait,
594    F: EntityTrait,
595{
596    /// Get a reference to the stored scope.
597    #[must_use]
598    pub fn scope(&self) -> &AccessScope {
599        &self.state.scope
600    }
601
602    /// Get the stored scope as an `Arc`.
603    #[must_use]
604    pub fn scope_arc(&self) -> Arc<AccessScope> {
605        Arc::clone(&self.state.scope)
606    }
607
608    /// Execute the query and return all matching results.
609    ///
610    /// Returns pairs of `(E::Model, Option<F::Model>)`.
611    ///
612    /// # Errors
613    /// Returns `ScopeError::Db` if the database query fails.
614    #[allow(clippy::disallowed_methods)]
615    pub async fn all(
616        self,
617        runner: &impl DBRunner,
618    ) -> Result<Vec<(E::Model, Option<F::Model>)>, ScopeError> {
619        match DBRunnerInternal::as_seaorm(runner) {
620            SeaOrmRunner::Conn(db) => Ok(self.inner.all(db).await?),
621            SeaOrmRunner::Tx(tx) => Ok(self.inner.all(tx).await?),
622        }
623    }
624
625    /// Execute the query and return at most one result.
626    ///
627    /// # Errors
628    /// Returns `ScopeError::Db` if the database query fails.
629    #[allow(clippy::disallowed_methods)]
630    pub async fn one(
631        self,
632        runner: &impl DBRunner,
633    ) -> Result<Option<(E::Model, Option<F::Model>)>, ScopeError> {
634        match DBRunnerInternal::as_seaorm(runner) {
635            SeaOrmRunner::Conn(db) => Ok(self.inner.one(db).await?),
636            SeaOrmRunner::Tx(tx) => Ok(self.inner.one(tx).await?),
637        }
638    }
639
640    /// Add additional filters to the query.
641    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
642        self.inner = QueryFilter::filter(self.inner, filter);
643        self
644    }
645
646    /// Add ordering to the query.
647    pub fn order_by<C>(mut self, col: C, order: sea_orm::Order) -> Self
648    where
649        C: sea_orm::IntoSimpleExpr,
650    {
651        self.inner = QueryOrder::order_by(self.inner, col, order);
652        self
653    }
654
655    /// Add a limit to the query.
656    pub fn limit(mut self, limit: u64) -> Self {
657        self.inner = QuerySelect::limit(self.inner, limit);
658        self
659    }
660
661    /// Unwrap the inner `SeaORM` `SelectTwo` for advanced use cases.
662    #[must_use]
663    pub fn into_inner(self) -> sea_orm::SelectTwo<E, F> {
664        self.inner
665    }
666}
667
668// =============================================================================
669// SecureSelectTwoMany<E, F, Scoped> - Execution methods
670// =============================================================================
671
672impl<E, F> SecureSelectTwoMany<E, F, Scoped>
673where
674    E: EntityTrait,
675    F: EntityTrait,
676{
677    /// Get a reference to the stored scope.
678    #[must_use]
679    pub fn scope(&self) -> &AccessScope {
680        &self.state.scope
681    }
682
683    /// Get the stored scope as an `Arc`.
684    #[must_use]
685    pub fn scope_arc(&self) -> Arc<AccessScope> {
686        Arc::clone(&self.state.scope)
687    }
688
689    /// Execute the query and return all matching results.
690    ///
691    /// Returns pairs of `(E::Model, Vec<F::Model>)`.
692    ///
693    /// # Errors
694    /// Returns `ScopeError::Db` if the database query fails.
695    #[allow(clippy::disallowed_methods)]
696    pub async fn all(
697        self,
698        runner: &impl DBRunner,
699    ) -> Result<Vec<(E::Model, Vec<F::Model>)>, ScopeError> {
700        match DBRunnerInternal::as_seaorm(runner) {
701            SeaOrmRunner::Conn(db) => Ok(self.inner.all(db).await?),
702            SeaOrmRunner::Tx(tx) => Ok(self.inner.all(tx).await?),
703        }
704    }
705
706    /// Add additional filters to the query.
707    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
708        self.inner = QueryFilter::filter(self.inner, filter);
709        self
710    }
711
712    /// Add ordering to the query.
713    pub fn order_by<C>(mut self, col: C, order: sea_orm::Order) -> Self
714    where
715        C: sea_orm::IntoSimpleExpr,
716    {
717        self.inner = QueryOrder::order_by(self.inner, col, order);
718        self
719    }
720
721    /// Unwrap the inner `SeaORM` `SelectTwoMany` for advanced use cases.
722    #[must_use]
723    pub fn into_inner(self) -> sea_orm::SelectTwoMany<E, F> {
724        self.inner
725    }
726}
727
728// =============================================================================
729// Model-level find_related Extension Trait
730// =============================================================================
731
732/// Extension trait to perform secure `find_related` queries from a model instance.
733///
734/// This trait provides a way to find entities related to an already-loaded model
735/// while maintaining security scope constraints.
736///
737/// # Example
738/// ```rust,ignore
739/// use modkit_db::secure::{AccessScope, SecureFindRelatedExt};
740///
741/// // Load a cake
742/// let cake: cake::Model = db.find_by_id::<cake::Entity>(&scope, cake_id)?
743///     .one(db)
744///     .await?
745///     .unwrap();
746///
747/// // Find all related fruits with scoping
748/// let fruits: Vec<fruit::Model> = cake
749///     .secure_find_related(fruit::Entity, &scope)
750///     .all(db)
751///     .await?;
752/// ```
753pub trait SecureFindRelatedExt: ModelTrait {
754    /// Find related entities with access scope applied.
755    ///
756    /// This creates a scoped query for entities related to this model.
757    /// The scope is applied to the related entity to ensure tenant isolation.
758    ///
759    /// # Type Parameters
760    /// - `R`: The related entity type that must implement `ScopableEntity`
761    ///
762    /// # Arguments
763    /// - `r`: The related entity marker (e.g., `fruit::Entity`)
764    /// - `scope`: The access scope to apply to the related entity query
765    fn secure_find_related<R>(&self, r: R, scope: &AccessScope) -> SecureSelect<R, Scoped>
766    where
767        R: ScopableEntity + EntityTrait,
768        R::Column: ColumnTrait + Copy,
769        Self::Entity: Related<R>;
770}
771
772impl<M> SecureFindRelatedExt for M
773where
774    M: ModelTrait,
775{
776    fn secure_find_related<R>(&self, r: R, scope: &AccessScope) -> SecureSelect<R, Scoped>
777    where
778        R: ScopableEntity + EntityTrait,
779        R::Column: ColumnTrait + Copy,
780        Self::Entity: Related<R>,
781    {
782        // Use SeaORM's find_related to build the base query
783        let select = self.find_related(r);
784
785        // Apply scope to the related entity
786        select.secure().scope_with(scope)
787    }
788}
789
790#[cfg(test)]
791#[cfg_attr(coverage_nightly, coverage(off))]
792mod tests {
793    use super::*;
794    use modkit_security::pep_properties;
795
796    // Note: Full integration tests with real SeaORM entities should be written
797    // in application code where actual entities are available.
798    // The typestate pattern is enforced at compile time.
799    //
800    // See USAGE_EXAMPLE.md for complete usage patterns.
801
802    #[test]
803    fn test_typestate_markers_exist() {
804        // This test verifies the typestate markers compile
805        // The actual enforcement happens at compile time
806        let unscoped = Unscoped;
807        assert!(std::mem::size_of_val(&unscoped) == 0); // Unscoped is zero-sized
808
809        // Scoped now requires an AccessScope
810        let scope = AccessScope::default();
811        let scoped = Scoped {
812            scope: Arc::new(scope),
813        };
814        assert!(!scoped.scope.has_property(pep_properties::OWNER_TENANT_ID)); // default scope has no tenants
815    }
816
817    #[test]
818    fn test_scoped_state_holds_scope() {
819        let tenant_id = uuid::Uuid::new_v4();
820        let scope = AccessScope::for_tenants(vec![tenant_id]);
821        let scoped = Scoped {
822            scope: Arc::new(scope),
823        };
824
825        // Verify the scope is accessible
826        assert!(scoped.scope.has_property(pep_properties::OWNER_TENANT_ID));
827        assert_eq!(
828            scoped
829                .scope
830                .all_values_for(pep_properties::OWNER_TENANT_ID)
831                .len(),
832            1
833        );
834        assert!(
835            scoped
836                .scope
837                .all_uuid_values_for(pep_properties::OWNER_TENANT_ID)
838                .contains(&tenant_id)
839        );
840    }
841
842    #[test]
843    fn test_scoped_state_is_cloneable() {
844        let scope = AccessScope::for_tenants(vec![uuid::Uuid::new_v4()]);
845        let scoped = Scoped {
846            scope: Arc::new(scope),
847        };
848
849        // Cloning should share the Arc
850        let cloned = scoped.clone();
851        assert!(Arc::ptr_eq(&scoped.scope, &cloned.scope));
852    }
853}