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/// Convert a `sea_orm::Value` to a [`ScopeValue`] for comparison with scope filter values.
16///
17/// Supports UUID, String. Returns `None` for unsupported types.
18fn sea_value_to_scope_value(v: &sea_orm::Value) -> Option<modkit_security::ScopeValue> {
19    use modkit_security::ScopeValue;
20    match v {
21        sea_orm::Value::Uuid(Some(u)) => Some(ScopeValue::Uuid(**u)),
22        sea_orm::Value::String(Some(s)) => {
23            // Try UUID first for consistent matching
24            if let Ok(uuid) = uuid::Uuid::parse_str(s) {
25                Some(ScopeValue::Uuid(uuid))
26            } else {
27                Some(ScopeValue::String(s.to_string()))
28            }
29        }
30        sea_orm::Value::BigInt(Some(n)) => Some(ScopeValue::Int(*n)),
31        sea_orm::Value::Int(Some(n)) => Some(ScopeValue::Int(i64::from(*n))),
32        sea_orm::Value::SmallInt(Some(n)) => Some(ScopeValue::Int(i64::from(*n))),
33        sea_orm::Value::TinyInt(Some(n)) => Some(ScopeValue::Int(i64::from(*n))),
34        sea_orm::Value::Bool(Some(b)) => Some(ScopeValue::Bool(*b)),
35        _ => None,
36    }
37}
38
39/// Validate that the values in an `ActiveModel` satisfy at least one constraint
40/// in the provided `AccessScope`.
41///
42/// This is the INSERT-time counterpart of `build_scope_condition`: instead of
43/// adding `WHERE` clauses to a query, it checks the `ActiveModel`'s column
44/// values in-memory against every scope filter.
45///
46/// # Semantics
47///
48/// - Multiple constraints are **OR-ed**: the insert is allowed if ANY constraint
49///   matches entirely.
50/// - Filters within a constraint are **AND-ed**: ALL filters must match for
51///   that constraint to pass.
52/// - A filter whose property resolves to a column where the `ActiveModel` value
53///   is `NotSet` is **skipped** (the column is not being inserted, so there's
54///   nothing to validate).
55/// - A filter whose property does **not** resolve (unknown property) causes
56///   that constraint to fail (fail-closed), consistent with the query-path
57///   behavior in `build_scope_condition`.
58///
59/// # Errors
60///
61/// Returns `ScopeError::Denied` if no constraint matches the `ActiveModel`.
62fn validate_insert_scope<A>(am: &A, scope: &AccessScope) -> Result<(), ScopeError>
63where
64    A: ActiveModelTrait,
65    A::Entity: ScopableEntity + EntityTrait,
66    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
67{
68    if scope.is_unconstrained() || A::Entity::IS_UNRESTRICTED {
69        return Ok(());
70    }
71    if scope.is_deny_all() {
72        return Err(ScopeError::Denied(
73            "insert denied: scope has no constraints",
74        ));
75    }
76
77    // OR over constraints: at least one must match entirely.
78    'next_constraint: for constraint in scope.constraints() {
79        // AND over filters within this constraint.
80        for filter in constraint.filters() {
81            let Some(col) = <A::Entity as ScopableEntity>::resolve_property(filter.property())
82            else {
83                // Unknown property → this constraint fails (fail-closed).
84                continue 'next_constraint;
85            };
86
87            // Extract the column value from the ActiveModel.
88            match am.get(col) {
89                sea_orm::ActiveValue::NotSet => {
90                    // Column not being set in this insert — skip this filter.
91                    // (e.g., auto-generated columns, defaults)
92                }
93                sea_orm::ActiveValue::Set(v) | sea_orm::ActiveValue::Unchanged(v) => {
94                    let Some(sv) = sea_value_to_scope_value(&v) else {
95                        // Unsupported column type — can't match filter.
96                        continue 'next_constraint;
97                    };
98
99                    if !filter.values().contains(&sv) {
100                        continue 'next_constraint;
101                    }
102                }
103            }
104        }
105        // All filters in this constraint matched → insert is allowed.
106        return Ok(());
107    }
108
109    Err(ScopeError::Denied(
110        "insert denied: entity values do not satisfy any scope constraint",
111    ))
112}
113
114/// Secure insert helper for Scopable entities.
115///
116/// This helper performs a standard `INSERT` through `SeaORM` but wraps database
117/// errors into a unified `ScopeError` type for consistent error handling across
118/// secure data-access code.
119///
120/// # Scope Validation
121///
122/// Validates **all** scope constraints against the `ActiveModel`'s column values,
123/// not just `tenant_id`. For each constraint in the scope, every filter's property
124/// is resolved to a column via `ScopableEntity::resolve_property`, and the
125/// `ActiveModel`'s value for that column is checked against the filter's values.
126/// At least one constraint must match entirely (OR semantics) for the insert to
127/// proceed.
128///
129/// # Responsibilities
130///
131/// - Does **not** inspect the `SecurityContext` or enforce tenant scoping rules.
132/// - Does **not** automatically populate any entity fields.
133/// - Callers are responsible for:
134///   - Setting all required fields before calling.
135///   - Validating that the operation is authorized within the current
136///     `SecurityContext` (e.g., verifying `tenant_id` or resource ownership).
137///
138/// # Behavior by Entity Type
139///
140/// ## Tenant-scoped entities (have `tenant_col`)
141/// - Must have a valid, non-empty `tenant_id` set in the `ActiveModel` before insert.
142/// - The `tenant_id` should come from the request payload or be validated against
143///   `SecurityContext` by the service layer before calling this helper.
144///
145/// ## Global entities (no `tenant_col`)
146/// - May be inserted freely without tenant validation.
147/// - Typical examples include system-wide configuration or audit logs.
148///
149/// # Recommended Field Population
150///
151/// When inserting entities, populate these fields from `SecurityContext` in service code:
152/// - `tenant_id`: from payload or validated via `ctx.scope()`
153/// - `owner_id`: from `ctx.subject_id()`
154/// - `created_by`: from `ctx.subject_id()` if applicable
155///
156/// # Example
157///
158/// ```ignore
159/// use modkit_db::secure::{secure_insert, SecurityContext};
160///
161/// // Domain/service layer validates tenant_id beforehand
162/// let am = user::ActiveModel {
163///     id: Set(Uuid::new_v4()),
164///     tenant_id: Set(tenant_id),
165///     owner_id: Set(ctx.subject_id()),
166///     email: Set("user@example.com".to_string()),
167///     ..Default::default()
168/// };
169///
170/// // Simple secure insert wrapper
171/// let user = secure_insert::<user::Entity>(am, &ctx, conn).await?;
172/// ```
173///
174/// # Errors
175///
176/// - Returns `ScopeError::Db` if the database insert fails.
177/// - Returns `ScopeError::Denied` if the `ActiveModel` values do not satisfy any scope constraint.
178/// - Returns `ScopeError::TenantNotInScope` for tenant isolation violations.
179pub async fn secure_insert<E>(
180    am: E::ActiveModel,
181    scope: &AccessScope,
182    runner: &impl DBRunner,
183) -> Result<E::Model, ScopeError>
184where
185    E: ScopableEntity + EntityTrait,
186    E::Column: ColumnTrait + Copy,
187    E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
188    E::Model: sea_orm::IntoActiveModel<E::ActiveModel>,
189{
190    // Tenant-scoped entities must have tenant_id set in the ActiveModel.
191    if let Some(tenant_col) = E::tenant_col()
192        && let sea_orm::ActiveValue::NotSet = am.get(tenant_col)
193    {
194        return Err(ScopeError::Invalid("tenant_id is required"));
195    }
196
197    validate_insert_scope(&am, scope)?;
198
199    match DBRunnerInternal::as_seaorm(runner) {
200        SeaOrmRunner::Conn(db) => Ok(am.insert(db).await?),
201        SeaOrmRunner::Tx(tx) => Ok(am.insert(tx).await?),
202    }
203}
204
205/// Secure update helper for updating a single entity by ID inside a scope.
206///
207/// # Security
208/// - Verifies the target row exists **within the scope** before updating.
209/// - For tenant-scoped entities, forbids changing `tenant_id` (immutable).
210///
211/// # Errors
212/// - `ScopeError::Denied` if the row is not accessible in the scope.
213/// - `ScopeError::Denied("tenant_id is immutable")` if caller attempts to change `tenant_id`.
214pub async fn secure_update_with_scope<E>(
215    am: E::ActiveModel,
216    scope: &AccessScope,
217    id: uuid::Uuid,
218    runner: &impl DBRunner,
219) -> Result<E::Model, ScopeError>
220where
221    E: ScopableEntity + EntityTrait,
222    E::Column: ColumnTrait + Copy,
223    E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
224    E::Model: sea_orm::IntoActiveModel<E::ActiveModel> + sea_orm::ModelTrait<Entity = E>,
225{
226    let existing = E::find()
227        .secure()
228        .scope_with(scope)
229        .and_id(id)?
230        .one(runner)
231        .await?;
232
233    let Some(existing) = existing else {
234        return Err(ScopeError::Denied(
235            "entity not found or not accessible in current security scope",
236        ));
237    };
238
239    if let Some(tcol) = E::tenant_col() {
240        let stored = match existing.get(tcol) {
241            sea_orm::Value::Uuid(Some(u)) => *u,
242            _ => return Err(ScopeError::Invalid("tenant_id has unexpected type")),
243        };
244
245        let incoming = match am.get(tcol) {
246            sea_orm::ActiveValue::Set(v) | sea_orm::ActiveValue::Unchanged(v) => match v {
247                sea_orm::Value::Uuid(Some(u)) => Some(*u),
248                sea_orm::Value::Uuid(None) => {
249                    return Err(ScopeError::Invalid("tenant_id is required"));
250                }
251                _ => {
252                    return Err(ScopeError::Invalid("tenant_id has unexpected type"));
253                }
254            },
255            sea_orm::ActiveValue::NotSet => None,
256        };
257
258        if let Some(incoming) = incoming
259            && incoming != stored
260        {
261            return Err(ScopeError::Denied("tenant_id is immutable"));
262        }
263    }
264
265    match DBRunnerInternal::as_seaorm(runner) {
266        SeaOrmRunner::Conn(db) => Ok(am.update(db).await?),
267        SeaOrmRunner::Tx(tx) => Ok(am.update(tx).await?),
268    }
269}
270
271/// Helper to validate a tenant ID is in the scope.
272///
273/// Use this when manually setting `tenant_id` in `ActiveModels` to ensure
274/// the value matches the security scope.
275///
276/// For unconstrained scopes (allow-all), this always succeeds.
277///
278/// # Errors
279/// Returns `ScopeError::Denied` if tenant scope is missing.
280/// Returns `ScopeError::TenantNotInScope` if the tenant ID is not in any constraint.
281pub fn validate_tenant_in_scope(
282    tenant_id: uuid::Uuid,
283    scope: &AccessScope,
284) -> Result<(), ScopeError> {
285    if scope.is_unconstrained() {
286        return Ok(());
287    }
288    let prop = modkit_security::pep_properties::OWNER_TENANT_ID;
289    if !scope.has_property(prop) {
290        return Err(ScopeError::Denied(
291            "tenant scope required for tenant-scoped insert",
292        ));
293    }
294    if scope.contains_uuid(prop, tenant_id) {
295        return Ok(());
296    }
297    Err(ScopeError::TenantNotInScope { tenant_id })
298}
299
300/// A type-safe wrapper around `SeaORM`'s `Insert` that enforces scoping.
301///
302/// This wrapper uses the typestate pattern to ensure that insert operations
303/// cannot be executed without first applying access control via
304/// `.scope_with_model()` (validated) or `.scope_unchecked()` (unvalidated).
305///
306/// Unlike the simpler `secure_insert()` helper, this wrapper preserves `SeaORM`'s
307/// builder methods like `on_conflict()` for upsert semantics.
308///
309/// # Example
310/// ```ignore
311/// use modkit_db::secure::{AccessScope, SecureInsertExt};
312/// use sea_orm::sea_query::OnConflict;
313///
314/// let scope = AccessScope::for_tenants(vec![tenant_id]);
315/// let am = user::ActiveModel {
316///     tenant_id: Set(tenant_id),
317///     email: Set("user@example.com".to_string()),
318///     ..Default::default()
319/// };
320///
321/// user::Entity::insert(am)
322///     .secure()                        // Returns SecureInsertOne<E, Unscoped>
323///     .scope_with_model(&scope, &am)?  // Returns SecureInsertOne<E, Scoped>
324///     .on_conflict(OnConflict::...)     // Builder methods still available
325///     .exec(conn)                  // Now can execute
326///     .await?;
327/// ```
328#[derive(Debug)]
329pub struct SecureInsertOne<A, S>
330where
331    A: ActiveModelTrait,
332{
333    pub(crate) inner: sea_orm::Insert<A>,
334    pub(crate) _state: PhantomData<S>,
335}
336
337/// Extension trait to convert a regular `SeaORM` `Insert` into a `SecureInsertOne`.
338pub trait SecureInsertExt<A: ActiveModelTrait>: Sized {
339    /// Convert this insert operation into a secure (unscoped) insert.
340    /// You must call `.scope_with_model()` or `.scope_unchecked()` before executing.
341    fn secure(self) -> SecureInsertOne<A, Unscoped>;
342}
343
344impl<A> SecureInsertExt<A> for sea_orm::Insert<A>
345where
346    A: ActiveModelTrait,
347{
348    fn secure(self) -> SecureInsertOne<A, Unscoped> {
349        SecureInsertOne {
350            inner: self,
351            _state: PhantomData,
352        }
353    }
354}
355
356// Methods available only on Unscoped inserts
357impl<A> SecureInsertOne<A, Unscoped>
358where
359    A: ActiveModelTrait + Send,
360    A::Entity: ScopableEntity + EntityTrait,
361    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
362{
363    /// Transition to `Scoped` state **without** validating the `ActiveModel`
364    /// against the scope constraints.
365    ///
366    /// # Safety (logical)
367    ///
368    /// This method performs **no** validation. The caller is responsible for
369    /// ensuring the `ActiveModel` satisfies the scope (e.g., correct
370    /// `tenant_id`). Prefer [`scope_with_model`](Self::scope_with_model)
371    /// which validates all scope constraints automatically.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`ScopeError`] if the access scope cannot be applied.
376    pub fn scope_unchecked(
377        self,
378        scope: &AccessScope,
379    ) -> Result<SecureInsertOne<A, Scoped>, ScopeError> {
380        let _ = scope;
381        Ok(SecureInsertOne {
382            inner: self.inner,
383            _state: PhantomData,
384        })
385    }
386
387    /// Apply access control scope with explicit `ActiveModel` validation.
388    ///
389    /// This method validates **all** scope constraints against the `ActiveModel`'s
390    /// column values (not just `tenant_id`). See [`validate_insert_scope`] for
391    /// the full semantics.
392    ///
393    /// # Errors
394    /// - Returns `ScopeError::Denied` if the `ActiveModel` values do not satisfy
395    ///   any scope constraint.
396    pub fn scope_with_model(
397        self,
398        scope: &AccessScope,
399        am: &A,
400    ) -> Result<SecureInsertOne<A, Scoped>, ScopeError> {
401        validate_insert_scope(am, scope)?;
402        Ok(SecureInsertOne {
403            inner: self.inner,
404            _state: PhantomData,
405        })
406    }
407}
408
409// Fluent builder methods (available only on Scoped inserts to prevent pre-scope execution)
410impl<A> SecureInsertOne<A, Scoped>
411where
412    A: ActiveModelTrait,
413    A::Entity: ScopableEntity + EntityTrait,
414    <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
415{
416    /// Set the `ON CONFLICT` clause for upsert semantics using `SecureOnConflict`.
417    ///
418    /// This is the recommended way to add upsert semantics as it enforces
419    /// tenant immutability at compile/validation time.
420    ///
421    /// # Example
422    ///
423    /// ```ignore
424    /// let on_conflict = SecureOnConflict::<Entity>::columns([Column::TenantId, Column::UserId])
425    ///     .update_columns([Column::Theme, Column::Language])?;
426    ///
427    /// Entity::insert(am)
428    ///     .secure()
429    ///     .scope_unchecked(&scope)?
430    ///     .on_conflict(on_conflict)
431    ///     .exec(conn)
432    ///     .await?;
433    /// ```
434    #[must_use]
435    pub fn on_conflict(mut self, on_conflict: SecureOnConflict<A::Entity>) -> Self {
436        self.inner = self.inner.on_conflict(on_conflict.build());
437        self
438    }
439
440    /// Set the `ON CONFLICT` clause using raw `SeaORM` `OnConflict`.
441    ///
442    /// # Safety
443    ///
444    /// This method bypasses tenant immutability validation. The caller is
445    /// responsible for ensuring that `tenant_id` is not included in update columns.
446    /// Use `on_conflict()` with `SecureOnConflict` for automatic validation.
447    #[must_use]
448    pub fn on_conflict_raw(mut self, on_conflict: OnConflict) -> Self {
449        self.inner = self.inner.on_conflict(on_conflict);
450        self
451    }
452}
453
454// Execution methods (require Scoped state)
455impl<A> SecureInsertOne<A, Scoped>
456where
457    A: ActiveModelTrait,
458{
459    /// Execute the insert operation.
460    ///
461    /// # Errors
462    /// Returns `ScopeError::Db` if the database operation fails.
463    #[allow(clippy::disallowed_methods)]
464    pub async fn exec<C>(self, runner: &C) -> Result<InsertResult<A>, ScopeError>
465    where
466        C: DBRunner,
467        A: Send,
468    {
469        match DBRunnerInternal::as_seaorm(runner) {
470            SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
471            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
472        }
473    }
474
475    /// Execute the insert and return the inserted model.
476    ///
477    /// This is useful when you need the inserted data with any database-generated
478    /// values (like auto-increment IDs or default values).
479    ///
480    /// # Errors
481    /// Returns `ScopeError::Db` if the database operation fails.
482    #[allow(clippy::disallowed_methods)]
483    pub async fn exec_with_returning<C>(
484        self,
485        runner: &C,
486    ) -> Result<<A::Entity as EntityTrait>::Model, ScopeError>
487    where
488        C: DBRunner,
489        A: Send,
490        <A::Entity as EntityTrait>::Model: IntoActiveModel<A>,
491    {
492        match DBRunnerInternal::as_seaorm(runner) {
493            SeaOrmRunner::Conn(db) => Ok(self.inner.exec_with_returning(db).await?),
494            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec_with_returning(tx).await?),
495        }
496    }
497
498    /// Unwrap the inner `SeaORM` `Insert` for advanced use cases.
499    ///
500    /// # Safety
501    /// The caller must ensure they don't remove or bypass the security
502    /// validation that was applied during `.scope_with_model()` / `.scope_unchecked()`.
503    #[must_use]
504    pub fn into_inner(self) -> sea_orm::Insert<A> {
505        self.inner
506    }
507}
508
509/// A secure builder for `ON CONFLICT DO UPDATE` clauses that enforces tenant immutability.
510///
511/// For tenant-scoped entities (`ScopableEntity::tenant_col() != None`), this builder
512/// ensures that `tenant_id` is never included in the update columns. Attempting to
513/// update `tenant_id` via `update_columns()` or `value()` returns an error.
514///
515/// # Security Rationale
516///
517/// `ON CONFLICT DO UPDATE` can be exploited to change an entity's tenant:
518/// ```sql
519/// INSERT INTO users (id, tenant_id, email) VALUES ($1, $2, $3)
520/// ON CONFLICT (id) DO UPDATE SET tenant_id = excluded.tenant_id;
521/// ```
522/// This would allow moving a row from one tenant to another, violating tenant isolation.
523///
524/// # Example
525///
526/// ```ignore
527/// use modkit_db::secure::{SecureOnConflict, SecureInsertExt};
528/// use sea_orm::ActiveValue::Set;
529///
530/// let scope = AccessScope::single(ScopeConstraint::new(vec![
531///     ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
532///     ScopeFilter::in_uuids(pep_properties::RESOURCE_ID, vec![user_id]),
533/// ]));
534/// let am = settings::ActiveModel {
535///     tenant_id: Set(tenant_id),
536///     user_id: Set(user_id),
537///     theme: Set(Some("dark".to_string())),
538///     language: Set(Some("en".to_string())),
539/// };
540///
541/// // Build secure on_conflict - validates tenant_id is not updated
542/// let on_conflict = SecureOnConflict::<settings::Entity>::columns([
543///         settings::Column::TenantId,
544///         settings::Column::UserId,
545///     ])
546///     .update_columns([settings::Column::Theme, settings::Column::Language])?;
547///
548/// settings::Entity::insert(am)
549///     .secure()
550///     .scope_unchecked(&scope)?
551///     .on_conflict(on_conflict)
552///     .exec(conn)
553///     .await?;
554/// ```
555#[derive(Debug, Clone)]
556pub struct SecureOnConflict<E: EntityTrait> {
557    inner: OnConflict,
558    _entity: PhantomData<E>,
559}
560
561impl<E> SecureOnConflict<E>
562where
563    E: ScopableEntity + EntityTrait,
564    E::Column: ColumnTrait + Copy,
565{
566    /// Start building an `ON CONFLICT` clause with the specified conflict columns.
567    ///
568    /// These are the columns that define uniqueness (typically the primary key
569    /// or a unique constraint).
570    #[must_use]
571    pub fn columns<C, I>(cols: I) -> Self
572    where
573        C: IntoIden,
574        I: IntoIterator<Item = C>,
575    {
576        Self {
577            inner: OnConflict::columns(cols),
578            _entity: PhantomData,
579        }
580    }
581
582    /// Specify columns to update on conflict.
583    ///
584    /// # Errors
585    ///
586    /// Returns `ScopeError::Denied("tenant_id is immutable")` if the entity has
587    /// a tenant column and it appears in the update columns list.
588    pub fn update_columns<C, I>(mut self, cols: I) -> Result<Self, ScopeError>
589    where
590        C: IntoIden + Copy + 'static,
591        I: IntoIterator<Item = C>,
592    {
593        let cols: Vec<C> = cols.into_iter().collect();
594
595        // Check if tenant column is in the update list
596        if let Some(tenant_col) = E::tenant_col() {
597            let tenant_iden = tenant_col.into_iden();
598            for col in &cols {
599                let col_iden = col.into_iden();
600                if col_iden.to_string() == tenant_iden.to_string() {
601                    return Err(ScopeError::Denied("tenant_id is immutable"));
602                }
603            }
604        }
605
606        self.inner.update_columns(cols);
607        Ok(self)
608    }
609
610    /// Set a custom update expression for a column on conflict.
611    ///
612    /// # Errors
613    ///
614    /// Returns `ScopeError::Denied("tenant_id is immutable")` if the entity has
615    /// a tenant column and the specified column matches it.
616    pub fn value<C>(mut self, col: C, expr: SimpleExpr) -> Result<Self, ScopeError>
617    where
618        C: IntoIden + Copy + 'static,
619    {
620        // Check if this is the tenant column
621        if let Some(tenant_col) = E::tenant_col() {
622            let tenant_iden = tenant_col.into_iden();
623            let col_iden = col.into_iden();
624            if col_iden.to_string() == tenant_iden.to_string() {
625                return Err(ScopeError::Denied("tenant_id is immutable"));
626            }
627        }
628
629        self.inner.value(col, expr);
630        Ok(self)
631    }
632
633    /// Consume the builder and return the underlying `SeaORM` `OnConflict`.
634    ///
635    /// Call this after configuring all update columns/values.
636    #[must_use]
637    pub fn build(self) -> OnConflict {
638        self.inner
639    }
640
641    /// Get a reference to the inner `OnConflict` for chaining with `SeaORM` methods
642    /// that are not wrapped by this builder.
643    ///
644    /// # Safety
645    ///
646    /// The caller must ensure they don't add tenant column updates through the
647    /// inner `OnConflict` directly, as this would bypass the security check.
648    #[must_use]
649    pub fn inner_mut(&mut self) -> &mut OnConflict {
650        &mut self.inner
651    }
652}
653
654/// A type-safe wrapper around `SeaORM`'s `UpdateMany` that enforces scoping.
655///
656/// This wrapper uses the typestate pattern to ensure that update operations
657/// cannot be executed without first applying access control via `.scope_with()`.
658///
659/// # Example
660/// ```ignore
661/// use modkit_db::secure::{AccessScope, SecureUpdateExt};
662///
663/// let scope = AccessScope::for_tenants(vec![tenant_id]);
664/// let result = user::Entity::update_many()
665///     .col_expr(user::Column::Status, Expr::value("active"))
666///     .secure()           // Returns SecureUpdateMany<E, Unscoped>
667///     .scope_with(&scope)? // Returns SecureUpdateMany<E, Scoped>
668///     .exec(conn)         // Now can execute
669///     .await?;
670/// ```
671#[derive(Clone, Debug)]
672pub struct SecureUpdateMany<E: EntityTrait, S> {
673    pub(crate) inner: sea_orm::UpdateMany<E>,
674    pub(crate) _state: PhantomData<S>,
675    pub(crate) tenant_update_attempted: bool,
676}
677
678// Fluent builder methods (available in all typestates).
679impl<E, S> SecureUpdateMany<E, S>
680where
681    E: ScopableEntity + EntityTrait,
682    E::Column: ColumnTrait + Copy,
683{
684    /// Set a column expression (mirrors `SeaORM`'s `UpdateMany::col_expr`).
685    #[must_use]
686    pub fn col_expr(mut self, col: E::Column, expr: sea_orm::sea_query::SimpleExpr) -> Self {
687        if let Some(tcol) = E::tenant_col()
688            && std::mem::discriminant(&col) == std::mem::discriminant(&tcol)
689        {
690            self.tenant_update_attempted = true;
691        }
692        self.inner = self.inner.col_expr(col, expr);
693        self
694    }
695
696    /// Add an additional filter. Scope conditions remain in place once applied.
697    #[must_use]
698    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
699        self.inner = QueryFilter::filter(self.inner, filter);
700        self
701    }
702}
703
704/// Extension trait to convert a regular `SeaORM` `UpdateMany` into a `SecureUpdateMany`.
705pub trait SecureUpdateExt<E: EntityTrait>: Sized {
706    /// Convert this update operation into a secure (unscoped) update.
707    /// You must call `.scope_with()` before executing.
708    fn secure(self) -> SecureUpdateMany<E, Unscoped>;
709}
710
711impl<E> SecureUpdateExt<E> for sea_orm::UpdateMany<E>
712where
713    E: EntityTrait,
714{
715    fn secure(self) -> SecureUpdateMany<E, Unscoped> {
716        SecureUpdateMany {
717            inner: self,
718            _state: PhantomData,
719            tenant_update_attempted: false,
720        }
721    }
722}
723
724// Methods available only on Unscoped updates
725impl<E> SecureUpdateMany<E, Unscoped>
726where
727    E: ScopableEntity + EntityTrait,
728    E::Column: ColumnTrait + Copy,
729{
730    /// Apply access control scope to this update, transitioning to the `Scoped` state.
731    ///
732    /// This applies the implicit policy:
733    /// - Empty scope → deny all (no rows updated)
734    /// - Tenants only → update only in specified tenants
735    /// - Resources only → update only specified resource IDs
736    /// - Both → AND them together
737    ///
738    #[must_use]
739    pub fn scope_with(self, scope: &AccessScope) -> SecureUpdateMany<E, Scoped> {
740        let cond = build_scope_condition::<E>(scope);
741        SecureUpdateMany {
742            inner: self.inner.filter(cond),
743            _state: PhantomData,
744            tenant_update_attempted: self.tenant_update_attempted,
745        }
746    }
747}
748
749// Methods available only on Scoped updates
750impl<E> SecureUpdateMany<E, Scoped>
751where
752    E: EntityTrait,
753{
754    /// Execute the update operation.
755    ///
756    /// # Errors
757    /// Returns `ScopeError::Db` if the database operation fails.
758    #[allow(clippy::disallowed_methods)]
759    pub async fn exec(self, runner: &impl DBRunner) -> Result<sea_orm::UpdateResult, ScopeError> {
760        if self.tenant_update_attempted {
761            return Err(ScopeError::Denied("tenant_id is immutable"));
762        }
763        match DBRunnerInternal::as_seaorm(runner) {
764            SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
765            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
766        }
767    }
768
769    /// Unwrap the inner `SeaORM` `UpdateMany` for advanced use cases.
770    ///
771    /// # Safety
772    /// The caller must ensure they don't remove or bypass the security
773    /// conditions that were applied during `.scope_with()`.
774    #[must_use]
775    pub fn into_inner(self) -> sea_orm::UpdateMany<E> {
776        self.inner
777    }
778}
779
780/// A type-safe wrapper around `SeaORM`'s `DeleteMany` that enforces scoping.
781///
782/// This wrapper uses the typestate pattern to ensure that delete operations
783/// cannot be executed without first applying access control via `.scope_with()`.
784///
785/// # Example
786/// ```ignore
787/// use modkit_db::secure::{AccessScope, SecureDeleteExt};
788///
789/// let scope = AccessScope::for_tenants(vec![tenant_id]);
790/// let result = user::Entity::delete_many()
791///     .filter(user::Column::Status.eq("inactive"))
792///     .secure()           // Returns SecureDeleteMany<E, Unscoped>
793///     .scope_with(&scope)? // Returns SecureDeleteMany<E, Scoped>
794///     .exec(conn)         // Now can execute
795///     .await?;
796/// ```
797#[derive(Clone, Debug)]
798pub struct SecureDeleteMany<E: EntityTrait, S> {
799    pub(crate) inner: sea_orm::DeleteMany<E>,
800    pub(crate) _state: PhantomData<S>,
801}
802
803/// Extension trait to convert a regular `SeaORM` `DeleteMany` into a `SecureDeleteMany`.
804pub trait SecureDeleteExt<E: EntityTrait>: Sized {
805    /// Convert this delete operation into a secure (unscoped) delete.
806    /// You must call `.scope_with()` before executing.
807    fn secure(self) -> SecureDeleteMany<E, Unscoped>;
808}
809
810impl<E> SecureDeleteExt<E> for sea_orm::DeleteMany<E>
811where
812    E: EntityTrait,
813{
814    fn secure(self) -> SecureDeleteMany<E, Unscoped> {
815        SecureDeleteMany {
816            inner: self,
817            _state: PhantomData,
818        }
819    }
820}
821
822// Methods available only on Unscoped deletes
823impl<E> SecureDeleteMany<E, Unscoped>
824where
825    E: ScopableEntity + EntityTrait,
826    E::Column: ColumnTrait + Copy,
827{
828    /// Apply access control scope to this delete, transitioning to the `Scoped` state.
829    ///
830    /// This applies the implicit policy:
831    /// - Empty scope → deny all (no rows deleted)
832    /// - Tenants only → delete only in specified tenants
833    /// - Resources only → delete only specified resource IDs
834    /// - Both → AND them together
835    ///
836    #[must_use]
837    pub fn scope_with(self, scope: &AccessScope) -> SecureDeleteMany<E, Scoped> {
838        let cond = build_scope_condition::<E>(scope);
839        SecureDeleteMany {
840            inner: self.inner.filter(cond),
841            _state: PhantomData,
842        }
843    }
844}
845
846// Methods available only on Scoped deletes
847impl<E> SecureDeleteMany<E, Scoped>
848where
849    E: EntityTrait,
850{
851    /// Add additional filters to the scoped delete.
852    /// The scope conditions remain in place.
853    #[must_use]
854    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
855        self.inner = QueryFilter::filter(self.inner, filter);
856        self
857    }
858
859    /// Execute the delete operation.
860    ///
861    /// # Errors
862    /// Returns `ScopeError::Db` if the database operation fails.
863    #[allow(clippy::disallowed_methods)]
864    pub async fn exec(self, runner: &impl DBRunner) -> Result<sea_orm::DeleteResult, ScopeError> {
865        match DBRunnerInternal::as_seaorm(runner) {
866            SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
867            SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
868        }
869    }
870
871    /// Unwrap the inner `SeaORM` `DeleteMany` for advanced use cases.
872    ///
873    /// # Safety
874    /// The caller must ensure they don't remove or bypass the security
875    /// conditions that were applied during `.scope_with()`.
876    #[must_use]
877    pub fn into_inner(self) -> sea_orm::DeleteMany<E> {
878        self.inner
879    }
880}
881
882#[cfg(test)]
883#[cfg_attr(coverage_nightly, coverage(off))]
884mod tests {
885    use super::*;
886    use sea_orm::entity::prelude::*;
887
888    // Test entity with tenant_col for SecureOnConflict tests
889    mod test_entity {
890        use super::*;
891        use modkit_security::pep_properties;
892
893        #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
894        #[sea_orm(table_name = "test_table")]
895        pub struct Model {
896            #[sea_orm(primary_key)]
897            pub id: Uuid,
898            pub tenant_id: Uuid,
899            pub name: String,
900            pub value: i32,
901        }
902
903        #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
904        pub enum Relation {}
905
906        impl ActiveModelBehavior for ActiveModel {}
907
908        impl ScopableEntity for Entity {
909            fn tenant_col() -> Option<Column> {
910                Some(Column::TenantId)
911            }
912            fn resource_col() -> Option<Column> {
913                Some(Column::Id)
914            }
915            fn owner_col() -> Option<Column> {
916                None
917            }
918            fn type_col() -> Option<Column> {
919                None
920            }
921            fn resolve_property(property: &str) -> Option<Column> {
922                match property {
923                    pep_properties::OWNER_TENANT_ID => Self::tenant_col(),
924                    pep_properties::RESOURCE_ID => Self::resource_col(),
925                    _ => None,
926                }
927            }
928        }
929    }
930
931    // Test entity without tenant_col (global entity)
932    mod global_entity {
933        use super::*;
934
935        #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
936        #[sea_orm(table_name = "global_table")]
937        pub struct Model {
938            #[sea_orm(primary_key)]
939            pub id: Uuid,
940            pub config_key: String,
941            pub config_value: String,
942        }
943
944        #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
945        pub enum Relation {}
946
947        impl ActiveModelBehavior for ActiveModel {}
948
949        impl ScopableEntity for Entity {
950            fn tenant_col() -> Option<Column> {
951                None // Global entity - no tenant column
952            }
953            fn resource_col() -> Option<Column> {
954                Some(Column::Id)
955            }
956            fn owner_col() -> Option<Column> {
957                None
958            }
959            fn type_col() -> Option<Column> {
960                None
961            }
962            fn resolve_property(property: &str) -> Option<Column> {
963                match property {
964                    "id" => Self::resource_col(),
965                    _ => None,
966                }
967            }
968        }
969    }
970
971    #[test]
972    fn test_validate_tenant_in_scope() {
973        let tenant_id = uuid::Uuid::new_v4();
974        let scope = crate::secure::AccessScope::for_tenants(vec![tenant_id]);
975
976        assert!(validate_tenant_in_scope(tenant_id, &scope).is_ok());
977
978        let other_id = uuid::Uuid::new_v4();
979        assert!(validate_tenant_in_scope(other_id, &scope).is_err());
980    }
981
982    // Note: Full integration tests with database require actual SeaORM entities
983    // These tests verify the typestate pattern compiles correctly
984
985    #[test]
986    fn test_typestate_compile_check() {
987        // This test verifies the typestate markers compile
988        let unscoped: PhantomData<Unscoped> = PhantomData;
989        let scoped: PhantomData<Scoped> = PhantomData;
990        // Use the variables to avoid unused warnings
991        let _ = (unscoped, scoped);
992    }
993
994    #[test]
995    fn test_tenant_not_in_scope_returns_error() {
996        // Verify that validate_tenant_in_scope properly rejects tenant IDs not in scope
997        let allowed_tenant = uuid::Uuid::new_v4();
998        let disallowed_tenant = uuid::Uuid::new_v4();
999        let scope = crate::secure::AccessScope::for_tenants(vec![allowed_tenant]);
1000
1001        // Allowed tenant should succeed
1002        assert!(validate_tenant_in_scope(allowed_tenant, &scope).is_ok());
1003
1004        // Disallowed tenant should fail with TenantNotInScope error
1005        let result = validate_tenant_in_scope(disallowed_tenant, &scope);
1006        assert!(result.is_err());
1007        match result {
1008            Err(ScopeError::TenantNotInScope { tenant_id }) => {
1009                assert_eq!(tenant_id, disallowed_tenant);
1010            }
1011            _ => panic!("Expected TenantNotInScope error"),
1012        }
1013    }
1014
1015    #[test]
1016    fn test_empty_scope_denied_for_tenant_scoped() {
1017        // Verify that an empty scope (no tenants) is rejected for tenant-scoped inserts
1018        let tenant_id = uuid::Uuid::new_v4();
1019        let empty_scope = crate::secure::AccessScope::default();
1020
1021        let result = validate_tenant_in_scope(tenant_id, &empty_scope);
1022        assert!(result.is_err());
1023        match result {
1024            Err(ScopeError::Denied(_)) => {}
1025            _ => panic!("Expected Denied error for empty scope"),
1026        }
1027    }
1028
1029    // SecureOnConflict tests
1030
1031    #[test]
1032    fn test_secure_on_conflict_update_columns_allows_non_tenant_columns() {
1033        use test_entity::{Column, Entity};
1034
1035        // update_columns with non-tenant columns should succeed
1036        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1037            .update_columns([Column::Name, Column::Value]);
1038
1039        assert!(result.is_ok());
1040    }
1041
1042    #[test]
1043    fn test_secure_on_conflict_update_columns_rejects_tenant_column() {
1044        use test_entity::{Column, Entity};
1045
1046        // update_columns with tenant_id should fail
1047        let result = SecureOnConflict::<Entity>::columns([Column::Id]).update_columns([
1048            Column::Name,
1049            Column::TenantId,
1050            Column::Value,
1051        ]);
1052
1053        assert!(result.is_err());
1054        match result {
1055            Err(ScopeError::Denied(msg)) => {
1056                assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
1057            }
1058            _ => panic!("Expected Denied error for tenant_id in update_columns"),
1059        }
1060    }
1061
1062    #[test]
1063    fn test_secure_on_conflict_value_allows_non_tenant_columns() {
1064        use sea_orm::sea_query::Expr;
1065        use test_entity::{Column, Entity};
1066
1067        // value() with non-tenant column should succeed
1068        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1069            .value(Column::Name, Expr::value("test"));
1070
1071        assert!(result.is_ok());
1072    }
1073
1074    #[test]
1075    fn test_secure_on_conflict_value_rejects_tenant_column() {
1076        use sea_orm::sea_query::Expr;
1077        use test_entity::{Column, Entity};
1078
1079        // value() with tenant_id should fail
1080        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1081            .value(Column::TenantId, Expr::value(uuid::Uuid::new_v4()));
1082
1083        assert!(result.is_err());
1084        match result {
1085            Err(ScopeError::Denied(msg)) => {
1086                assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
1087            }
1088            _ => panic!("Expected Denied error for tenant_id in value()"),
1089        }
1090    }
1091
1092    #[test]
1093    fn test_secure_on_conflict_chained_value_rejects_tenant_column() {
1094        use sea_orm::sea_query::Expr;
1095        use test_entity::{Column, Entity};
1096
1097        // Chaining value() calls - should fail when tenant_id is added
1098        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1099            .value(Column::Name, Expr::value("test"))
1100            .and_then(|c| c.value(Column::TenantId, Expr::value(uuid::Uuid::new_v4())));
1101
1102        assert!(result.is_err());
1103        match result {
1104            Err(ScopeError::Denied(msg)) => {
1105                assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
1106            }
1107            _ => panic!("Expected Denied error for tenant_id in chained value()"),
1108        }
1109    }
1110
1111    #[test]
1112    fn test_secure_on_conflict_global_entity_allows_all_columns() {
1113        use global_entity::{Column, Entity};
1114
1115        // Global entity has no tenant_col, so all columns are allowed
1116        let result = SecureOnConflict::<Entity>::columns([Column::Id])
1117            .update_columns([Column::ConfigKey, Column::ConfigValue]);
1118
1119        assert!(result.is_ok());
1120    }
1121
1122    #[test]
1123    fn test_secure_on_conflict_build_produces_on_conflict() {
1124        use test_entity::{Column, Entity};
1125
1126        // Verify that build() produces a valid OnConflict
1127        let on_conflict = SecureOnConflict::<Entity>::columns([Column::Id])
1128            .update_columns([Column::Name, Column::Value])
1129            .expect("should succeed")
1130            .build();
1131
1132        // The OnConflict should be usable (we can't easily test its internals,
1133        // but we can verify it doesn't panic)
1134        _ = format!("{on_conflict:?}");
1135    }
1136
1137    // ── validate_insert_scope tests ─────────────────────────────────
1138
1139    // Test entity with owner_col and a custom pep_prop (city_id),
1140    // mimicking the Address entity from the users-info example.
1141    mod owner_entity {
1142        use super::*;
1143        use modkit_security::pep_properties;
1144
1145        #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
1146        #[sea_orm(table_name = "addresses")]
1147        pub struct Model {
1148            #[sea_orm(primary_key)]
1149            pub id: Uuid,
1150            pub tenant_id: Uuid,
1151            pub user_id: Uuid,
1152            pub city_id: Uuid,
1153        }
1154
1155        #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
1156        pub enum Relation {}
1157
1158        impl ActiveModelBehavior for ActiveModel {}
1159
1160        impl ScopableEntity for Entity {
1161            fn tenant_col() -> Option<Column> {
1162                Some(Column::TenantId)
1163            }
1164            fn resource_col() -> Option<Column> {
1165                Some(Column::Id)
1166            }
1167            fn owner_col() -> Option<Column> {
1168                Some(Column::UserId)
1169            }
1170            fn type_col() -> Option<Column> {
1171                None
1172            }
1173            fn resolve_property(property: &str) -> Option<Column> {
1174                match property {
1175                    pep_properties::OWNER_TENANT_ID => Some(Column::TenantId),
1176                    pep_properties::RESOURCE_ID => Some(Column::Id),
1177                    pep_properties::OWNER_ID => Some(Column::UserId),
1178                    "city_id" => Some(Column::CityId),
1179                    _ => None,
1180                }
1181            }
1182        }
1183    }
1184
1185    #[test]
1186    fn test_validate_insert_scope_allow_all_passes() {
1187        use owner_entity::ActiveModel;
1188        use sea_orm::Set;
1189
1190        let scope = crate::secure::AccessScope::allow_all();
1191        let am = ActiveModel {
1192            id: Set(Uuid::new_v4()),
1193            tenant_id: Set(Uuid::new_v4()),
1194            user_id: Set(Uuid::new_v4()),
1195            city_id: Set(Uuid::new_v4()),
1196        };
1197        assert!(validate_insert_scope(&am, &scope).is_ok());
1198    }
1199
1200    #[test]
1201    fn test_validate_insert_scope_deny_all_rejects() {
1202        use owner_entity::ActiveModel;
1203        use sea_orm::Set;
1204
1205        let scope = crate::secure::AccessScope::deny_all();
1206        let am = ActiveModel {
1207            id: Set(Uuid::new_v4()),
1208            tenant_id: Set(Uuid::new_v4()),
1209            user_id: Set(Uuid::new_v4()),
1210            city_id: Set(Uuid::new_v4()),
1211        };
1212        assert!(validate_insert_scope(&am, &scope).is_err());
1213    }
1214
1215    #[test]
1216    fn test_validate_insert_scope_tenant_only_matches() {
1217        use owner_entity::ActiveModel;
1218        use sea_orm::Set;
1219
1220        let tenant_id = Uuid::new_v4();
1221        let scope = crate::secure::AccessScope::for_tenant(tenant_id);
1222        let am = ActiveModel {
1223            id: Set(Uuid::new_v4()),
1224            tenant_id: Set(tenant_id),
1225            user_id: Set(Uuid::new_v4()),
1226            city_id: Set(Uuid::new_v4()),
1227        };
1228        assert!(validate_insert_scope(&am, &scope).is_ok());
1229    }
1230
1231    #[test]
1232    fn test_validate_insert_scope_tenant_mismatch_rejects() {
1233        use owner_entity::ActiveModel;
1234        use sea_orm::Set;
1235
1236        let tenant_id = Uuid::new_v4();
1237        let other_tenant = Uuid::new_v4();
1238        let scope = crate::secure::AccessScope::for_tenant(tenant_id);
1239        let am = ActiveModel {
1240            id: Set(Uuid::new_v4()),
1241            tenant_id: Set(other_tenant),
1242            user_id: Set(Uuid::new_v4()),
1243            city_id: Set(Uuid::new_v4()),
1244        };
1245        assert!(validate_insert_scope(&am, &scope).is_err());
1246    }
1247
1248    #[test]
1249    fn test_validate_insert_scope_owner_id_matches() {
1250        use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
1251        use modkit_security::pep_properties;
1252        use owner_entity::ActiveModel;
1253        use sea_orm::Set;
1254
1255        let tenant_id = Uuid::new_v4();
1256        let user_id = Uuid::new_v4();
1257        let city_id = Uuid::new_v4();
1258
1259        // Scope: tenant + owner_id + city_id (all must match)
1260        let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
1261            ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
1262            ScopeFilter::eq(pep_properties::OWNER_ID, user_id),
1263            ScopeFilter::eq("city_id", city_id),
1264        ])]);
1265
1266        let am = ActiveModel {
1267            id: Set(Uuid::new_v4()),
1268            tenant_id: Set(tenant_id),
1269            user_id: Set(user_id),
1270            city_id: Set(city_id),
1271        };
1272        assert!(
1273            validate_insert_scope(&am, &scope).is_ok(),
1274            "Insert should pass when all properties match"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_validate_insert_scope_owner_id_mismatch_rejects() {
1280        use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
1281        use modkit_security::pep_properties;
1282        use owner_entity::ActiveModel;
1283        use sea_orm::Set;
1284
1285        let tenant_id = Uuid::new_v4();
1286        let user_a = Uuid::new_v4();
1287        let user_b = Uuid::new_v4();
1288        let city_id = Uuid::new_v4();
1289
1290        // Scope says owner_id must be user_a
1291        let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
1292            ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
1293            ScopeFilter::eq(pep_properties::OWNER_ID, user_a),
1294            ScopeFilter::eq("city_id", city_id),
1295        ])]);
1296
1297        // But ActiveModel has user_id = user_b
1298        let am = ActiveModel {
1299            id: Set(Uuid::new_v4()),
1300            tenant_id: Set(tenant_id),
1301            user_id: Set(user_b),
1302            city_id: Set(city_id),
1303        };
1304        assert!(
1305            validate_insert_scope(&am, &scope).is_err(),
1306            "Insert must be rejected when owner_id doesn't match"
1307        );
1308    }
1309
1310    #[test]
1311    fn test_validate_insert_scope_city_id_mismatch_rejects() {
1312        use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
1313        use modkit_security::pep_properties;
1314        use owner_entity::ActiveModel;
1315        use sea_orm::Set;
1316
1317        let tenant_id = Uuid::new_v4();
1318        let user_id = Uuid::new_v4();
1319        let allowed_city = Uuid::new_v4();
1320        let disallowed_city = Uuid::new_v4();
1321
1322        // Scope says city_id must be allowed_city
1323        let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
1324            ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
1325            ScopeFilter::eq(pep_properties::OWNER_ID, user_id),
1326            ScopeFilter::eq("city_id", allowed_city),
1327        ])]);
1328
1329        // But ActiveModel has city_id = disallowed_city
1330        let am = ActiveModel {
1331            id: Set(Uuid::new_v4()),
1332            tenant_id: Set(tenant_id),
1333            user_id: Set(user_id),
1334            city_id: Set(disallowed_city),
1335        };
1336        assert!(
1337            validate_insert_scope(&am, &scope).is_err(),
1338            "Insert must be rejected when city_id doesn't match"
1339        );
1340    }
1341
1342    #[test]
1343    fn test_validate_insert_scope_or_semantics() {
1344        use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
1345        use modkit_security::pep_properties;
1346        use owner_entity::ActiveModel;
1347        use sea_orm::Set;
1348
1349        let tenant_id = Uuid::new_v4();
1350        let user_id = Uuid::new_v4();
1351        let city_1 = Uuid::new_v4();
1352        let city_2 = Uuid::new_v4();
1353
1354        // Two constraints (OR-ed): user allowed in city_1 OR city_2
1355        let scope = AccessScope::from_constraints(vec![
1356            ScopeConstraint::new(vec![
1357                ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
1358                ScopeFilter::eq("city_id", city_1),
1359            ]),
1360            ScopeConstraint::new(vec![
1361                ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
1362                ScopeFilter::eq("city_id", city_2),
1363            ]),
1364        ]);
1365
1366        // Insert with city_2 — matches second constraint
1367        let am = ActiveModel {
1368            id: Set(Uuid::new_v4()),
1369            tenant_id: Set(tenant_id),
1370            user_id: Set(user_id),
1371            city_id: Set(city_2),
1372        };
1373        assert!(
1374            validate_insert_scope(&am, &scope).is_ok(),
1375            "Insert should pass when matching any constraint (OR semantics)"
1376        );
1377
1378        // Insert with city_3 — matches neither
1379        let city_3 = Uuid::new_v4();
1380        let am_bad = ActiveModel {
1381            id: Set(Uuid::new_v4()),
1382            tenant_id: Set(tenant_id),
1383            user_id: Set(user_id),
1384            city_id: Set(city_3),
1385        };
1386        assert!(
1387            validate_insert_scope(&am_bad, &scope).is_err(),
1388            "Insert must be rejected when no constraint matches"
1389        );
1390    }
1391
1392    #[test]
1393    fn test_validate_insert_scope_unknown_property_fails_closed() {
1394        use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
1395        use modkit_security::pep_properties;
1396        use owner_entity::ActiveModel;
1397        use sea_orm::Set;
1398
1399        let tenant_id = Uuid::new_v4();
1400
1401        // Constraint with an unknown property
1402        let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
1403            ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
1404            ScopeFilter::eq("nonexistent_prop", Uuid::new_v4()),
1405        ])]);
1406
1407        let am = ActiveModel {
1408            id: Set(Uuid::new_v4()),
1409            tenant_id: Set(tenant_id),
1410            user_id: Set(Uuid::new_v4()),
1411            city_id: Set(Uuid::new_v4()),
1412        };
1413        assert!(
1414            validate_insert_scope(&am, &scope).is_err(),
1415            "Unknown property must cause constraint to fail (fail-closed)"
1416        );
1417    }
1418}