Skip to main content

modkit_db/secure/
db_ops.rs

1use sea_orm::{
2    ActiveModelTrait, ColumnTrait, EntityTrait, InsertResult, IntoActiveModel, ModelTrait,
3    QueryFilter,
4    sea_query::{IntoIden, OnConflict, SimpleExpr},
5};
6use std::marker::PhantomData;
7
8use crate::secure::cond::build_scope_condition;
9use crate::secure::error::ScopeError;
10use crate::secure::{
11    AccessScope, DBRunner, DBRunnerInternal, ScopableEntity, Scoped, SeaOrmRunner, SecureEntityExt,
12    Unscoped,
13};
14
15/// Controls how `NotSet` `tenant_id` is treated during extraction.
16#[derive(Clone, Copy)]
17enum TenantIdMode {
18    /// `NotSet` → error (for inserts where `tenant_id` is mandatory)
19    Required,
20    /// `NotSet` → `Ok(None)` (for updates where `tenant_id` may be unchanged)
21    Optional,
22}
23
24/// Core implementation for extracting `tenant_id` from an `ActiveModel`.
25fn extract_tenant_id_impl<A>(am: &A, mode: TenantIdMode) -> Result<Option<uuid::Uuid>, ScopeError>
26where
27    A: ActiveModelTrait,
28    A::Entity: ScopableEntity + EntityTrait,
29    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
30{
31    let Some(tcol) = <A::Entity as ScopableEntity>::tenant_col() else {
32        // Unrestricted/global table: no tenant dimension.
33        return Ok(None);
34    };
35
36    match am.get(tcol) {
37        sea_orm::ActiveValue::NotSet => match mode {
38            TenantIdMode::Required => Err(ScopeError::Invalid("tenant_id is required")),
39            TenantIdMode::Optional => Ok(None),
40        },
41        sea_orm::ActiveValue::Set(v) | sea_orm::ActiveValue::Unchanged(v) => match v {
42            sea_orm::Value::Uuid(Some(u)) => Ok(Some(*u)),
43            sea_orm::Value::Uuid(None) => Err(ScopeError::Invalid("tenant_id is required")),
44            _ => Err(ScopeError::Invalid("tenant_id has unexpected type")),
45        },
46    }
47}
48
49fn extract_tenant_id<E>(am: &E::ActiveModel) -> Result<Option<uuid::Uuid>, ScopeError>
50where
51    E: ScopableEntity + EntityTrait,
52    E::Column: ColumnTrait + Copy,
53    E::ActiveModel: ActiveModelTrait<Entity = E>,
54{
55    extract_tenant_id_impl(am, TenantIdMode::Required)
56}
57
58fn extract_tenant_id_if_present<E>(am: &E::ActiveModel) -> Result<Option<uuid::Uuid>, ScopeError>
59where
60    E: ScopableEntity + EntityTrait,
61    E::Column: ColumnTrait + Copy,
62    E::ActiveModel: ActiveModelTrait<Entity = E>,
63{
64    extract_tenant_id_impl(am, TenantIdMode::Optional)
65}
66
67/// Extract `tenant_id` from an `ActiveModel` (generic over `ActiveModel` type).
68///
69/// This variant works when you have the `ActiveModel` type directly rather than the `Entity` type.
70fn extract_tenant_id_from_am<A>(am: &A) -> Result<Option<uuid::Uuid>, ScopeError>
71where
72    A: ActiveModelTrait,
73    A::Entity: ScopableEntity + EntityTrait,
74    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
75{
76    extract_tenant_id_impl(am, TenantIdMode::Required)
77}
78
79/// Secure insert helper for Scopable entities.
80///
81/// This helper performs a standard `INSERT` through `SeaORM` but wraps database
82/// errors into a unified `ScopeError` type for consistent error handling across
83/// secure data-access code. For tenant-scoped entities it enforces tenant isolation
84/// by validating the `ActiveModel`'s `tenant_id` against the provided `AccessScope`.
85///
86/// # Responsibilities
87///
88/// - Does **not** inspect the `SecurityContext` or enforce tenant scoping rules.
89/// - Does **not** automatically populate any entity fields.
90/// - Callers are responsible for:
91///   - Setting all required fields before calling.
92///   - Validating that the operation is authorized within the current
93///     `SecurityContext` (e.g., verifying `tenant_id` or resource ownership).
94///
95/// # Behavior by Entity Type
96///
97/// ## Tenant-scoped entities (have `tenant_col`)
98/// - Must have a valid, non-empty `tenant_id` set in the `ActiveModel` before insert.
99/// - The `tenant_id` should come from the request payload or be validated against
100///   `SecurityContext` by the service layer before calling this helper.
101///
102/// ## Global entities (no `tenant_col`)
103/// - May be inserted freely without tenant validation.
104/// - Typical examples include system-wide configuration or audit logs.
105///
106/// # Recommended Field Population
107///
108/// When inserting entities, populate these fields from `SecurityContext` in service code:
109/// - `tenant_id`: from payload or validated via `ctx.scope()`
110/// - `owner_id`: from `ctx.subject_id()`
111/// - `created_by`: from `ctx.subject_id()` if applicable
112///
113/// # Example
114///
115/// ```ignore
116/// use modkit_db::secure::{secure_insert, SecurityContext};
117///
118/// // Domain/service layer validates tenant_id beforehand
119/// let am = user::ActiveModel {
120///     id: Set(Uuid::new_v4()),
121///     tenant_id: Set(tenant_id),
122///     owner_id: Set(ctx.subject_id()),
123///     email: Set("user@example.com".to_string()),
124///     ..Default::default()
125/// };
126///
127/// // Simple secure insert wrapper
128/// let user = secure_insert::<user::Entity>(am, &ctx, conn).await?;
129/// ```
130///
131/// # Errors
132///
133/// - Returns `ScopeError::Db` if the database insert fails.
134/// - Returns `ScopeError::Denied` / `ScopeError::TenantNotInScope` for tenant isolation violations.
135pub async fn secure_insert<E>(
136    am: E::ActiveModel,
137    scope: &AccessScope,
138    runner: &impl DBRunner,
139) -> Result<E::Model, ScopeError>
140where
141    E: ScopableEntity + EntityTrait,
142    E::Column: ColumnTrait + Copy,
143    E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
144    E::Model: sea_orm::IntoActiveModel<E::ActiveModel>,
145{
146    if let Some(tenant_id) = extract_tenant_id::<E>(&am)? {
147        validate_tenant_in_scope(tenant_id, scope)?;
148    }
149
150    match DBRunnerInternal::as_seaorm(runner) {
151        SeaOrmRunner::Conn(db) => Ok(am.insert(db).await?),
152        SeaOrmRunner::Tx(tx) => Ok(am.insert(tx).await?),
153    }
154}
155
156/// Secure update helper for updating a single entity by ID inside a scope.
157///
158/// # Security
159/// - Verifies the target row exists **within the scope** before updating.
160/// - For tenant-scoped entities, forbids changing `tenant_id` (immutable).
161///
162/// # Errors
163/// - `ScopeError::Denied` if the row is not accessible in the scope.
164/// - `ScopeError::Denied("tenant_id is immutable")` if caller attempts to change `tenant_id`.
165pub async fn secure_update_with_scope<E>(
166    am: E::ActiveModel,
167    scope: &AccessScope,
168    id: uuid::Uuid,
169    runner: &impl DBRunner,
170) -> Result<E::Model, ScopeError>
171where
172    E: ScopableEntity + EntityTrait,
173    E::Column: ColumnTrait + Copy,
174    E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
175    E::Model: sea_orm::IntoActiveModel<E::ActiveModel> + sea_orm::ModelTrait<Entity = E>,
176{
177    let existing = E::find()
178        .secure()
179        .scope_with(scope)
180        .and_id(id)?
181        .one(runner)
182        .await?;
183
184    let Some(existing) = existing else {
185        return Err(ScopeError::Denied(
186            "entity not found or not accessible in current security scope",
187        ));
188    };
189
190    if let Some(tcol) = E::tenant_col() {
191        let stored = match existing.get(tcol) {
192            sea_orm::Value::Uuid(Some(u)) => *u,
193            _ => return Err(ScopeError::Invalid("tenant_id has unexpected type")),
194        };
195
196        if let Some(incoming) = extract_tenant_id_if_present::<E>(&am)?
197            && incoming != stored
198        {
199            return Err(ScopeError::Denied("tenant_id is immutable"));
200        }
201    }
202
203    match DBRunnerInternal::as_seaorm(runner) {
204        SeaOrmRunner::Conn(db) => Ok(am.update(db).await?),
205        SeaOrmRunner::Tx(tx) => Ok(am.update(tx).await?),
206    }
207}
208
209/// Helper to validate a tenant ID is in the scope.
210///
211/// Use this when manually setting `tenant_id` in `ActiveModels` to ensure
212/// the value matches the security scope.
213///
214/// # Errors
215/// Returns `ScopeError::Invalid` if the tenant ID is not in the scope.
216pub fn validate_tenant_in_scope(
217    tenant_id: uuid::Uuid,
218    scope: &AccessScope,
219) -> Result<(), ScopeError> {
220    if !scope.has_tenants() {
221        return Err(ScopeError::Denied(
222            "tenant scope required for tenant-scoped insert",
223        ));
224    }
225    if scope.tenant_ids().contains(&tenant_id) {
226        return Ok(());
227    }
228    Err(ScopeError::TenantNotInScope { tenant_id })
229}
230
231/// A type-safe wrapper around `SeaORM`'s `Insert` that enforces scoping.
232///
233/// This wrapper uses the typestate pattern to ensure that insert operations
234/// cannot be executed without first applying access control via `.scope_with()`.
235///
236/// Unlike the simpler `secure_insert()` helper, this wrapper preserves `SeaORM`'s
237/// builder methods like `on_conflict()` for upsert semantics.
238///
239/// # Example
240/// ```ignore
241/// use modkit_db::secure::{AccessScope, SecureInsertExt};
242/// use sea_orm::sea_query::OnConflict;
243///
244/// let scope = AccessScope::tenants_only(vec![tenant_id]);
245/// let am = user::ActiveModel {
246///     tenant_id: Set(tenant_id),
247///     email: Set("user@example.com".to_string()),
248///     ..Default::default()
249/// };
250///
251/// user::Entity::insert(am)
252///     .secure()                    // Returns SecureInsertOne<E, Unscoped>
253///     .scope_with(&scope)?         // Returns SecureInsertOne<E, Scoped>
254///     .on_conflict(OnConflict::...) // Builder methods still available
255///     .exec(conn)                  // Now can execute
256///     .await?;
257/// ```
258#[derive(Debug)]
259pub struct SecureInsertOne<A, S>
260where
261    A: ActiveModelTrait,
262{
263    pub(crate) inner: sea_orm::Insert<A>,
264    pub(crate) _state: PhantomData<S>,
265}
266
267/// Extension trait to convert a regular `SeaORM` `Insert` into a `SecureInsertOne`.
268pub trait SecureInsertExt<A: ActiveModelTrait>: Sized {
269    /// Convert this insert operation into a secure (unscoped) insert.
270    /// You must call `.scope_with()` before executing.
271    fn secure(self) -> SecureInsertOne<A, Unscoped>;
272}
273
274impl<A> SecureInsertExt<A> for sea_orm::Insert<A>
275where
276    A: ActiveModelTrait,
277{
278    fn secure(self) -> SecureInsertOne<A, Unscoped> {
279        SecureInsertOne {
280            inner: self,
281            _state: PhantomData,
282        }
283    }
284}
285
286// Methods available only on Unscoped inserts
287impl<A> SecureInsertOne<A, Unscoped>
288where
289    A: ActiveModelTrait + Send,
290    A::Entity: ScopableEntity + EntityTrait,
291    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
292{
293    /// Apply access control scope to this insert, transitioning to the `Scoped` state.
294    ///
295    /// For tenant-scoped entities, this validates that the `tenant_id` in the
296    /// `ActiveModel` matches one of the tenants in the provided scope.
297    ///
298    /// # Errors
299    /// - Returns `ScopeError::Invalid` if `tenant_id` is not set for tenant-scoped entities.
300    /// - Returns `ScopeError::TenantNotInScope` if `tenant_id` is not in the provided scope.
301    pub fn scope_with(self, scope: &AccessScope) -> Result<SecureInsertOne<A, Scoped>, ScopeError> {
302        // For INSERT operations, we need to extract and validate the tenant from the
303        // ActiveModel being inserted. Unfortunately, SeaORM's Insert<A> doesn't expose
304        // the ActiveModel directly, so we rely on the caller to have already validated
305        // the scope when constructing the ActiveModel.
306        //
307        // The scope is still required to transition to Scoped state, ensuring the caller
308        // has an appropriate security context. For full tenant validation, use the
309        // `scope_with_model` method which takes the ActiveModel explicitly.
310        let _ = scope; // Mark as used - validation happens via scope_with_model
311        Ok(SecureInsertOne {
312            inner: self.inner,
313            _state: PhantomData,
314        })
315    }
316
317    /// Apply access control scope with explicit `ActiveModel` validation.
318    ///
319    /// This method extracts the `tenant_id` from the provided `ActiveModel` and
320    /// validates it against the provided scope before allowing the insert.
321    ///
322    /// # Errors
323    /// - Returns `ScopeError::Invalid` if `tenant_id` is not set for tenant-scoped entities.
324    /// - Returns `ScopeError::TenantNotInScope` if `tenant_id` is not in the provided scope.
325    pub fn scope_with_model(
326        self,
327        scope: &AccessScope,
328        am: &A,
329    ) -> Result<SecureInsertOne<A, Scoped>, ScopeError> {
330        if let Some(tenant_id) = extract_tenant_id_from_am(am)? {
331            validate_tenant_in_scope(tenant_id, scope)?;
332        }
333        Ok(SecureInsertOne {
334            inner: self.inner,
335            _state: PhantomData,
336        })
337    }
338}
339
340// Fluent builder methods (available only on Scoped inserts to prevent pre-scope execution)
341impl<A> SecureInsertOne<A, Scoped>
342where
343    A: ActiveModelTrait,
344    A::Entity: ScopableEntity + EntityTrait,
345    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
346{
347    /// Set the `ON CONFLICT` clause for upsert semantics using `SecureOnConflict`.
348    ///
349    /// This is the recommended way to add upsert semantics as it enforces
350    /// tenant immutability at compile/validation time.
351    ///
352    /// # Example
353    ///
354    /// ```ignore
355    /// let on_conflict = SecureOnConflict::<Entity>::columns([Column::TenantId, Column::UserId])
356    ///     .update_columns([Column::Theme, Column::Language])?;
357    ///
358    /// Entity::insert(am)
359    ///     .secure()
360    ///     .scope_with(&scope)?
361    ///     .on_conflict(on_conflict)
362    ///     .exec(conn)
363    ///     .await?;
364    /// ```
365    #[must_use]
366    pub fn on_conflict(mut self, on_conflict: SecureOnConflict<A::Entity>) -> Self {
367        self.inner = self.inner.on_conflict(on_conflict.build());
368        self
369    }
370
371    /// Set the `ON CONFLICT` clause using raw `SeaORM` `OnConflict`.
372    ///
373    /// # Safety
374    ///
375    /// This method bypasses tenant immutability validation. The caller is
376    /// responsible for ensuring that `tenant_id` is not included in update columns.
377    /// Use `on_conflict()` with `SecureOnConflict` for automatic validation.
378    #[must_use]
379    pub fn on_conflict_raw(mut self, on_conflict: OnConflict) -> Self {
380        self.inner = self.inner.on_conflict(on_conflict);
381        self
382    }
383}
384
385// Execution methods (require Scoped state)
386impl<A> SecureInsertOne<A, Scoped>
387where
388    A: ActiveModelTrait,
389{
390    /// Execute the insert operation.
391    ///
392    /// # Errors
393    /// Returns `ScopeError::Db` if the database operation fails.
394    #[allow(clippy::disallowed_methods)]
395    pub async fn exec<C>(self, runner: &C) -> Result<InsertResult<A>, ScopeError>
396    where
397        C: DBRunner,
398        A: Send,
399    {
400        match DBRunnerInternal::as_seaorm(runner) {
401            SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
402            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
403        }
404    }
405
406    /// Execute the insert and return the inserted model.
407    ///
408    /// This is useful when you need the inserted data with any database-generated
409    /// values (like auto-increment IDs or default values).
410    ///
411    /// # Errors
412    /// Returns `ScopeError::Db` if the database operation fails.
413    #[allow(clippy::disallowed_methods)]
414    pub async fn exec_with_returning<C>(
415        self,
416        runner: &C,
417    ) -> Result<<A::Entity as EntityTrait>::Model, ScopeError>
418    where
419        C: DBRunner,
420        A: Send,
421        <A::Entity as EntityTrait>::Model: IntoActiveModel<A>,
422    {
423        match DBRunnerInternal::as_seaorm(runner) {
424            SeaOrmRunner::Conn(db) => Ok(self.inner.exec_with_returning(db).await?),
425            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec_with_returning(tx).await?),
426        }
427    }
428
429    /// Unwrap the inner `SeaORM` `Insert` for advanced use cases.
430    ///
431    /// # Safety
432    /// The caller must ensure they don't remove or bypass the security
433    /// validation that was applied during `.scope_with()`.
434    #[must_use]
435    pub fn into_inner(self) -> sea_orm::Insert<A> {
436        self.inner
437    }
438}
439
440/// A secure builder for `ON CONFLICT DO UPDATE` clauses that enforces tenant immutability.
441///
442/// For tenant-scoped entities (`ScopableEntity::tenant_col() != None`), this builder
443/// ensures that `tenant_id` is never included in the update columns. Attempting to
444/// update `tenant_id` via `update_columns()` or `value()` returns an error.
445///
446/// # Security Rationale
447///
448/// `ON CONFLICT DO UPDATE` can be exploited to change an entity's tenant:
449/// ```sql
450/// INSERT INTO users (id, tenant_id, email) VALUES ($1, $2, $3)
451/// ON CONFLICT (id) DO UPDATE SET tenant_id = excluded.tenant_id;
452/// ```
453/// This would allow moving a row from one tenant to another, violating tenant isolation.
454///
455/// # Example
456///
457/// ```ignore
458/// use modkit_db::secure::{SecureOnConflict, SecureInsertExt};
459/// use sea_orm::ActiveValue::Set;
460///
461/// let scope = AccessScope::both(vec![tenant_id], vec![user_id]);
462/// let am = settings::ActiveModel {
463///     tenant_id: Set(tenant_id),
464///     user_id: Set(user_id),
465///     theme: Set(Some("dark".to_string())),
466///     language: Set(Some("en".to_string())),
467/// };
468///
469/// // Build secure on_conflict - validates tenant_id is not updated
470/// let on_conflict = SecureOnConflict::<settings::Entity>::columns([
471///         settings::Column::TenantId,
472///         settings::Column::UserId,
473///     ])
474///     .update_columns([settings::Column::Theme, settings::Column::Language])?;
475///
476/// settings::Entity::insert(am)
477///     .secure()
478///     .scope_with(&scope)?
479///     .on_conflict(on_conflict)
480///     .exec(conn)
481///     .await?;
482/// ```
483#[derive(Debug, Clone)]
484pub struct SecureOnConflict<E: EntityTrait> {
485    inner: OnConflict,
486    _entity: PhantomData<E>,
487}
488
489impl<E> SecureOnConflict<E>
490where
491    E: ScopableEntity + EntityTrait,
492    E::Column: ColumnTrait + Copy,
493{
494    /// Start building an `ON CONFLICT` clause with the specified conflict columns.
495    ///
496    /// These are the columns that define uniqueness (typically the primary key
497    /// or a unique constraint).
498    #[must_use]
499    pub fn columns<C, I>(cols: I) -> Self
500    where
501        C: IntoIden,
502        I: IntoIterator<Item = C>,
503    {
504        Self {
505            inner: OnConflict::columns(cols),
506            _entity: PhantomData,
507        }
508    }
509
510    /// Specify columns to update on conflict.
511    ///
512    /// # Errors
513    ///
514    /// Returns `ScopeError::Denied("tenant_id is immutable")` if the entity has
515    /// a tenant column and it appears in the update columns list.
516    pub fn update_columns<C, I>(mut self, cols: I) -> Result<Self, ScopeError>
517    where
518        C: IntoIden + Copy + 'static,
519        I: IntoIterator<Item = C>,
520    {
521        let cols: Vec<C> = cols.into_iter().collect();
522
523        // Check if tenant column is in the update list
524        if let Some(tenant_col) = E::tenant_col() {
525            let tenant_iden = tenant_col.into_iden();
526            for col in &cols {
527                let col_iden = col.into_iden();
528                if col_iden.to_string() == tenant_iden.to_string() {
529                    return Err(ScopeError::Denied("tenant_id is immutable"));
530                }
531            }
532        }
533
534        self.inner.update_columns(cols);
535        Ok(self)
536    }
537
538    /// Set a custom update expression for a column on conflict.
539    ///
540    /// # Errors
541    ///
542    /// Returns `ScopeError::Denied("tenant_id is immutable")` if the entity has
543    /// a tenant column and the specified column matches it.
544    pub fn value<C>(mut self, col: C, expr: SimpleExpr) -> Result<Self, ScopeError>
545    where
546        C: IntoIden + Copy + 'static,
547    {
548        // Check if this is the tenant column
549        if let Some(tenant_col) = E::tenant_col() {
550            let tenant_iden = tenant_col.into_iden();
551            let col_iden = col.into_iden();
552            if col_iden.to_string() == tenant_iden.to_string() {
553                return Err(ScopeError::Denied("tenant_id is immutable"));
554            }
555        }
556
557        self.inner.value(col, expr);
558        Ok(self)
559    }
560
561    /// Consume the builder and return the underlying `SeaORM` `OnConflict`.
562    ///
563    /// Call this after configuring all update columns/values.
564    #[must_use]
565    pub fn build(self) -> OnConflict {
566        self.inner
567    }
568
569    /// Get a reference to the inner `OnConflict` for chaining with `SeaORM` methods
570    /// that are not wrapped by this builder.
571    ///
572    /// # Safety
573    ///
574    /// The caller must ensure they don't add tenant column updates through the
575    /// inner `OnConflict` directly, as this would bypass the security check.
576    #[must_use]
577    pub fn inner_mut(&mut self) -> &mut OnConflict {
578        &mut self.inner
579    }
580}
581
582/// A type-safe wrapper around `SeaORM`'s `UpdateMany` that enforces scoping.
583///
584/// This wrapper uses the typestate pattern to ensure that update operations
585/// cannot be executed without first applying access control via `.scope_with()`.
586///
587/// # Example
588/// ```ignore
589/// use modkit_db::secure::{AccessScope, SecureUpdateExt};
590///
591/// let scope = AccessScope::tenants_only(vec![tenant_id]);
592/// let result = user::Entity::update_many()
593///     .col_expr(user::Column::Status, Expr::value("active"))
594///     .secure()           // Returns SecureUpdateMany<E, Unscoped>
595///     .scope_with(&scope)? // Returns SecureUpdateMany<E, Scoped>
596///     .exec(conn)         // Now can execute
597///     .await?;
598/// ```
599#[derive(Clone, Debug)]
600pub struct SecureUpdateMany<E: EntityTrait, S> {
601    pub(crate) inner: sea_orm::UpdateMany<E>,
602    pub(crate) _state: PhantomData<S>,
603    pub(crate) tenant_update_attempted: bool,
604}
605
606// Fluent builder methods (available in all typestates).
607impl<E, S> SecureUpdateMany<E, S>
608where
609    E: ScopableEntity + EntityTrait,
610    E::Column: ColumnTrait + Copy,
611{
612    /// Set a column expression (mirrors `SeaORM`'s `UpdateMany::col_expr`).
613    #[must_use]
614    pub fn col_expr(mut self, col: E::Column, expr: sea_orm::sea_query::SimpleExpr) -> Self {
615        if let Some(tcol) = E::tenant_col()
616            && std::mem::discriminant(&col) == std::mem::discriminant(&tcol)
617        {
618            self.tenant_update_attempted = true;
619        }
620        self.inner = self.inner.col_expr(col, expr);
621        self
622    }
623
624    /// Add an additional filter. Scope conditions remain in place once applied.
625    #[must_use]
626    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
627        self.inner = QueryFilter::filter(self.inner, filter);
628        self
629    }
630}
631
632/// Extension trait to convert a regular `SeaORM` `UpdateMany` into a `SecureUpdateMany`.
633pub trait SecureUpdateExt<E: EntityTrait>: Sized {
634    /// Convert this update operation into a secure (unscoped) update.
635    /// You must call `.scope_with()` before executing.
636    fn secure(self) -> SecureUpdateMany<E, Unscoped>;
637}
638
639impl<E> SecureUpdateExt<E> for sea_orm::UpdateMany<E>
640where
641    E: EntityTrait,
642{
643    fn secure(self) -> SecureUpdateMany<E, Unscoped> {
644        SecureUpdateMany {
645            inner: self,
646            _state: PhantomData,
647            tenant_update_attempted: false,
648        }
649    }
650}
651
652// Methods available only on Unscoped updates
653impl<E> SecureUpdateMany<E, Unscoped>
654where
655    E: ScopableEntity + EntityTrait,
656    E::Column: ColumnTrait + Copy,
657{
658    /// Apply access control scope to this update, transitioning to the `Scoped` state.
659    ///
660    /// This applies the implicit policy:
661    /// - Empty scope → deny all (no rows updated)
662    /// - Tenants only → update only in specified tenants
663    /// - Resources only → update only specified resource IDs
664    /// - Both → AND them together
665    ///
666    #[must_use]
667    pub fn scope_with(self, scope: &AccessScope) -> SecureUpdateMany<E, Scoped> {
668        let cond = build_scope_condition::<E>(scope);
669        SecureUpdateMany {
670            inner: self.inner.filter(cond),
671            _state: PhantomData,
672            tenant_update_attempted: self.tenant_update_attempted,
673        }
674    }
675}
676
677// Methods available only on Scoped updates
678impl<E> SecureUpdateMany<E, Scoped>
679where
680    E: EntityTrait,
681{
682    /// Execute the update operation.
683    ///
684    /// # Errors
685    /// Returns `ScopeError::Db` if the database operation fails.
686    #[allow(clippy::disallowed_methods)]
687    pub async fn exec(self, runner: &impl DBRunner) -> Result<sea_orm::UpdateResult, ScopeError> {
688        if self.tenant_update_attempted {
689            return Err(ScopeError::Denied("tenant_id is immutable"));
690        }
691        match DBRunnerInternal::as_seaorm(runner) {
692            SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
693            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
694        }
695    }
696
697    /// Unwrap the inner `SeaORM` `UpdateMany` for advanced use cases.
698    ///
699    /// # Safety
700    /// The caller must ensure they don't remove or bypass the security
701    /// conditions that were applied during `.scope_with()`.
702    #[must_use]
703    pub fn into_inner(self) -> sea_orm::UpdateMany<E> {
704        self.inner
705    }
706}
707
708/// A type-safe wrapper around `SeaORM`'s `DeleteMany` that enforces scoping.
709///
710/// This wrapper uses the typestate pattern to ensure that delete operations
711/// cannot be executed without first applying access control via `.scope_with()`.
712///
713/// # Example
714/// ```ignore
715/// use modkit_db::secure::{AccessScope, SecureDeleteExt};
716///
717/// let scope = AccessScope::tenants_only(vec![tenant_id]);
718/// let result = user::Entity::delete_many()
719///     .filter(user::Column::Status.eq("inactive"))
720///     .secure()           // Returns SecureDeleteMany<E, Unscoped>
721///     .scope_with(&scope)? // Returns SecureDeleteMany<E, Scoped>
722///     .exec(conn)         // Now can execute
723///     .await?;
724/// ```
725#[derive(Clone, Debug)]
726pub struct SecureDeleteMany<E: EntityTrait, S> {
727    pub(crate) inner: sea_orm::DeleteMany<E>,
728    pub(crate) _state: PhantomData<S>,
729}
730
731/// Extension trait to convert a regular `SeaORM` `DeleteMany` into a `SecureDeleteMany`.
732pub trait SecureDeleteExt<E: EntityTrait>: Sized {
733    /// Convert this delete operation into a secure (unscoped) delete.
734    /// You must call `.scope_with()` before executing.
735    fn secure(self) -> SecureDeleteMany<E, Unscoped>;
736}
737
738impl<E> SecureDeleteExt<E> for sea_orm::DeleteMany<E>
739where
740    E: EntityTrait,
741{
742    fn secure(self) -> SecureDeleteMany<E, Unscoped> {
743        SecureDeleteMany {
744            inner: self,
745            _state: PhantomData,
746        }
747    }
748}
749
750// Methods available only on Unscoped deletes
751impl<E> SecureDeleteMany<E, Unscoped>
752where
753    E: ScopableEntity + EntityTrait,
754    E::Column: ColumnTrait + Copy,
755{
756    /// Apply access control scope to this delete, transitioning to the `Scoped` state.
757    ///
758    /// This applies the implicit policy:
759    /// - Empty scope → deny all (no rows deleted)
760    /// - Tenants only → delete only in specified tenants
761    /// - Resources only → delete only specified resource IDs
762    /// - Both → AND them together
763    ///
764    #[must_use]
765    pub fn scope_with(self, scope: &AccessScope) -> SecureDeleteMany<E, Scoped> {
766        let cond = build_scope_condition::<E>(scope);
767        SecureDeleteMany {
768            inner: self.inner.filter(cond),
769            _state: PhantomData,
770        }
771    }
772}
773
774// Methods available only on Scoped deletes
775impl<E> SecureDeleteMany<E, Scoped>
776where
777    E: EntityTrait,
778{
779    /// Add additional filters to the scoped delete.
780    /// The scope conditions remain in place.
781    #[must_use]
782    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
783        self.inner = QueryFilter::filter(self.inner, filter);
784        self
785    }
786
787    /// Execute the delete operation.
788    ///
789    /// # Errors
790    /// Returns `ScopeError::Db` if the database operation fails.
791    #[allow(clippy::disallowed_methods)]
792    pub async fn exec(self, runner: &impl DBRunner) -> Result<sea_orm::DeleteResult, ScopeError> {
793        match DBRunnerInternal::as_seaorm(runner) {
794            SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
795            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
796        }
797    }
798
799    /// Unwrap the inner `SeaORM` `DeleteMany` for advanced use cases.
800    ///
801    /// # Safety
802    /// The caller must ensure they don't remove or bypass the security
803    /// conditions that were applied during `.scope_with()`.
804    #[must_use]
805    pub fn into_inner(self) -> sea_orm::DeleteMany<E> {
806        self.inner
807    }
808}
809
810#[cfg(test)]
811#[cfg_attr(coverage_nightly, coverage(off))]
812mod tests {
813    use super::*;
814    use sea_orm::entity::prelude::*;
815
816    // Test entity with tenant_col for SecureOnConflict tests
817    mod test_entity {
818        use super::*;
819
820        #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
821        #[sea_orm(table_name = "test_table")]
822        pub struct Model {
823            #[sea_orm(primary_key)]
824            pub id: Uuid,
825            pub tenant_id: Uuid,
826            pub name: String,
827            pub value: i32,
828        }
829
830        #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
831        pub enum Relation {}
832
833        impl ActiveModelBehavior for ActiveModel {}
834
835        impl ScopableEntity for Entity {
836            fn tenant_col() -> Option<Column> {
837                Some(Column::TenantId)
838            }
839            fn resource_col() -> Option<Column> {
840                Some(Column::Id)
841            }
842            fn owner_col() -> Option<Column> {
843                None
844            }
845            fn type_col() -> Option<Column> {
846                None
847            }
848        }
849    }
850
851    // Test entity without tenant_col (global entity)
852    mod global_entity {
853        use super::*;
854
855        #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
856        #[sea_orm(table_name = "global_table")]
857        pub struct Model {
858            #[sea_orm(primary_key)]
859            pub id: Uuid,
860            pub config_key: String,
861            pub config_value: String,
862        }
863
864        #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
865        pub enum Relation {}
866
867        impl ActiveModelBehavior for ActiveModel {}
868
869        impl ScopableEntity for Entity {
870            fn tenant_col() -> Option<Column> {
871                None // Global entity - no tenant column
872            }
873            fn resource_col() -> Option<Column> {
874                Some(Column::Id)
875            }
876            fn owner_col() -> Option<Column> {
877                None
878            }
879            fn type_col() -> Option<Column> {
880                None
881            }
882        }
883    }
884
885    #[test]
886    fn test_validate_tenant_in_scope() {
887        let tenant_id = uuid::Uuid::new_v4();
888        let scope = crate::secure::AccessScope::tenants_only(vec![tenant_id]);
889
890        assert!(validate_tenant_in_scope(tenant_id, &scope).is_ok());
891
892        let other_id = uuid::Uuid::new_v4();
893        assert!(validate_tenant_in_scope(other_id, &scope).is_err());
894    }
895
896    // Note: Full integration tests with database require actual SeaORM entities
897    // These tests verify the typestate pattern compiles correctly
898
899    #[test]
900    fn test_typestate_compile_check() {
901        // This test verifies the typestate markers compile
902        let unscoped: PhantomData<Unscoped> = PhantomData;
903        let scoped: PhantomData<Scoped> = PhantomData;
904        // Use the variables to avoid unused warnings
905        let _ = (unscoped, scoped);
906    }
907
908    #[test]
909    fn test_tenant_not_in_scope_returns_error() {
910        // Verify that validate_tenant_in_scope properly rejects tenant IDs not in scope
911        let allowed_tenant = uuid::Uuid::new_v4();
912        let disallowed_tenant = uuid::Uuid::new_v4();
913        let scope = crate::secure::AccessScope::tenants_only(vec![allowed_tenant]);
914
915        // Allowed tenant should succeed
916        assert!(validate_tenant_in_scope(allowed_tenant, &scope).is_ok());
917
918        // Disallowed tenant should fail with TenantNotInScope error
919        let result = validate_tenant_in_scope(disallowed_tenant, &scope);
920        assert!(result.is_err());
921        match result {
922            Err(ScopeError::TenantNotInScope { tenant_id }) => {
923                assert_eq!(tenant_id, disallowed_tenant);
924            }
925            _ => panic!("Expected TenantNotInScope error"),
926        }
927    }
928
929    #[test]
930    fn test_empty_scope_denied_for_tenant_scoped() {
931        // Verify that an empty scope (no tenants) is rejected for tenant-scoped inserts
932        let tenant_id = uuid::Uuid::new_v4();
933        let empty_scope = crate::secure::AccessScope::default();
934
935        let result = validate_tenant_in_scope(tenant_id, &empty_scope);
936        assert!(result.is_err());
937        match result {
938            Err(ScopeError::Denied(_)) => {}
939            _ => panic!("Expected Denied error for empty scope"),
940        }
941    }
942
943    // SecureOnConflict tests
944
945    #[test]
946    fn test_secure_on_conflict_update_columns_allows_non_tenant_columns() {
947        use test_entity::{Column, Entity};
948
949        // update_columns with non-tenant columns should succeed
950        let result = SecureOnConflict::<Entity>::columns([Column::Id])
951            .update_columns([Column::Name, Column::Value]);
952
953        assert!(result.is_ok());
954    }
955
956    #[test]
957    fn test_secure_on_conflict_update_columns_rejects_tenant_column() {
958        use test_entity::{Column, Entity};
959
960        // update_columns with tenant_id should fail
961        let result = SecureOnConflict::<Entity>::columns([Column::Id]).update_columns([
962            Column::Name,
963            Column::TenantId,
964            Column::Value,
965        ]);
966
967        assert!(result.is_err());
968        match result {
969            Err(ScopeError::Denied(msg)) => {
970                assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
971            }
972            _ => panic!("Expected Denied error for tenant_id in update_columns"),
973        }
974    }
975
976    #[test]
977    fn test_secure_on_conflict_value_allows_non_tenant_columns() {
978        use sea_orm::sea_query::Expr;
979        use test_entity::{Column, Entity};
980
981        // value() with non-tenant column should succeed
982        let result = SecureOnConflict::<Entity>::columns([Column::Id])
983            .value(Column::Name, Expr::value("test"));
984
985        assert!(result.is_ok());
986    }
987
988    #[test]
989    fn test_secure_on_conflict_value_rejects_tenant_column() {
990        use sea_orm::sea_query::Expr;
991        use test_entity::{Column, Entity};
992
993        // value() with tenant_id should fail
994        let result = SecureOnConflict::<Entity>::columns([Column::Id])
995            .value(Column::TenantId, Expr::value(uuid::Uuid::new_v4()));
996
997        assert!(result.is_err());
998        match result {
999            Err(ScopeError::Denied(msg)) => {
1000                assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
1001            }
1002            _ => panic!("Expected Denied error for tenant_id in value()"),
1003        }
1004    }
1005
1006    #[test]
1007    fn test_secure_on_conflict_chained_value_rejects_tenant_column() {
1008        use sea_orm::sea_query::Expr;
1009        use test_entity::{Column, Entity};
1010
1011        // Chaining value() calls - should fail when tenant_id is added
1012        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1013            .value(Column::Name, Expr::value("test"))
1014            .and_then(|c| c.value(Column::TenantId, Expr::value(uuid::Uuid::new_v4())));
1015
1016        assert!(result.is_err());
1017        match result {
1018            Err(ScopeError::Denied(msg)) => {
1019                assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
1020            }
1021            _ => panic!("Expected Denied error for tenant_id in chained value()"),
1022        }
1023    }
1024
1025    #[test]
1026    fn test_secure_on_conflict_global_entity_allows_all_columns() {
1027        use global_entity::{Column, Entity};
1028
1029        // Global entity has no tenant_col, so all columns are allowed
1030        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1031            .update_columns([Column::ConfigKey, Column::ConfigValue]);
1032
1033        assert!(result.is_ok());
1034    }
1035
1036    #[test]
1037    fn test_secure_on_conflict_build_produces_on_conflict() {
1038        use test_entity::{Column, Entity};
1039
1040        // Verify that build() produces a valid OnConflict
1041        let on_conflict = SecureOnConflict::<Entity>::columns([Column::Id])
1042            .update_columns([Column::Name, Column::Value])
1043            .expect("should succeed")
1044            .build();
1045
1046        // The OnConflict should be usable (we can't easily test its internals,
1047        // but we can verify it doesn't panic)
1048        let _ = format!("{on_conflict:?}");
1049    }
1050}