Skip to main content

modkit_db/secure/
db_ops.rs

1use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
2use std::marker::PhantomData;
3
4use crate::secure::cond::build_scope_condition;
5use crate::secure::error::ScopeError;
6use crate::secure::{AccessScope, ScopableEntity, Scoped, Unscoped};
7
8/// Secure insert helper for Scopable entities.
9///
10/// This helper performs a standard `INSERT` through `SeaORM` but wraps database
11/// errors into a unified `ScopeError` type for consistent error handling across
12/// secure data-access code. It does **not** enforce any authorization or tenant
13/// checks on its own.
14///
15/// # Responsibilities
16///
17/// - Does **not** inspect the `SecurityContext` or enforce tenant scoping rules.
18/// - Does **not** automatically populate any entity fields.
19/// - Callers are responsible for:
20///   - Setting all required fields before calling.
21///   - Validating that the operation is authorized within the current
22///     `SecurityContext` (e.g., verifying `tenant_id` or resource ownership).
23///
24/// # Behavior by Entity Type
25///
26/// ## Tenant-scoped entities (have `tenant_col`)
27/// - Must have a valid, non-empty `tenant_id` set in the `ActiveModel` before insert.
28/// - The `tenant_id` should come from the request payload or be validated against
29///   `SecurityContext` by the service layer before calling this helper.
30///
31/// ## Global entities (no `tenant_col`)
32/// - May be inserted freely without tenant validation.
33/// - Typical examples include system-wide configuration or audit logs.
34///
35/// # Recommended Field Population
36///
37/// When inserting entities, populate these fields from `SecurityContext` in service code:
38/// - `tenant_id`: from payload or validated via `ctx.scope()`
39/// - `owner_id`: from `ctx.subject_id()`
40/// - `created_by`: from `ctx.subject_id()` if applicable
41///
42/// # Example
43///
44/// ```ignore
45/// use modkit_db::secure::{secure_insert, SecurityContext};
46///
47/// // Domain/service layer validates tenant_id beforehand
48/// let am = user::ActiveModel {
49///     id: Set(Uuid::new_v4()),
50///     tenant_id: Set(tenant_id),
51///     owner_id: Set(ctx.subject_id()),
52///     email: Set("user@example.com".to_string()),
53///     ..Default::default()
54/// };
55///
56/// // Simple secure insert wrapper
57/// let user = secure_insert::<user::Entity>(am, &ctx, conn).await?;
58/// ```
59///
60/// # Errors
61///
62/// - Returns `ScopeError::Db` if the database insert fails.
63/// - Does **not** return scope or authorization errors; these must be handled
64///   in higher layers (e.g., service logic or request handlers).
65pub async fn secure_insert<E>(
66    am: E::ActiveModel,
67    _scope: &AccessScope,
68    conn: &impl ConnectionTrait,
69) -> Result<E::Model, ScopeError>
70where
71    E: ScopableEntity + EntityTrait,
72    E::Column: ColumnTrait + Copy,
73    E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
74    E::Model: sea_orm::IntoActiveModel<E::ActiveModel>,
75{
76    // No tenant validation is performed here — caller is responsible.
77    Ok(am.insert(conn).await?)
78}
79
80/// Helper to validate a tenant ID is in the scope.
81///
82/// Use this when manually setting `tenant_id` in `ActiveModels` to ensure
83/// the value matches the security scope.
84///
85/// # Errors
86/// Returns `ScopeError::Invalid` if the tenant ID is not in the scope.
87pub fn validate_tenant_in_scope(
88    tenant_id: uuid::Uuid,
89    scope: &AccessScope,
90) -> Result<(), ScopeError> {
91    if scope.tenant_ids().contains(&tenant_id) {
92        Ok(())
93    } else {
94        Err(ScopeError::Invalid(
95            "tenant_id not present in security scope",
96        ))
97    }
98}
99
100/// A type-safe wrapper around `SeaORM`'s `UpdateMany` that enforces scoping.
101///
102/// This wrapper uses the typestate pattern to ensure that update operations
103/// cannot be executed without first applying access control via `.scope_with()`.
104///
105/// # Example
106/// ```ignore
107/// use modkit_db::secure::{AccessScope, SecureUpdateExt};
108///
109/// let scope = AccessScope::tenants_only(vec![tenant_id]);
110/// let result = user::Entity::update_many()
111///     .col_expr(user::Column::Status, Expr::value("active"))
112///     .secure()           // Returns SecureUpdateMany<E, Unscoped>
113///     .scope_with(&scope)? // Returns SecureUpdateMany<E, Scoped>
114///     .exec(conn)         // Now can execute
115///     .await?;
116/// ```
117#[derive(Clone, Debug)]
118pub struct SecureUpdateMany<E: EntityTrait, S> {
119    pub(crate) inner: sea_orm::UpdateMany<E>,
120    pub(crate) _state: PhantomData<S>,
121}
122
123/// Extension trait to convert a regular `SeaORM` `UpdateMany` into a `SecureUpdateMany`.
124pub trait SecureUpdateExt<E: EntityTrait>: Sized {
125    /// Convert this update operation into a secure (unscoped) update.
126    /// You must call `.scope_with()` before executing.
127    fn secure(self) -> SecureUpdateMany<E, Unscoped>;
128}
129
130impl<E> SecureUpdateExt<E> for sea_orm::UpdateMany<E>
131where
132    E: EntityTrait,
133{
134    fn secure(self) -> SecureUpdateMany<E, Unscoped> {
135        SecureUpdateMany {
136            inner: self,
137            _state: PhantomData,
138        }
139    }
140}
141
142// Methods available only on Unscoped updates
143impl<E> SecureUpdateMany<E, Unscoped>
144where
145    E: ScopableEntity + EntityTrait,
146    E::Column: ColumnTrait + Copy,
147{
148    /// Apply access control scope to this update, transitioning to the `Scoped` state.
149    ///
150    /// This applies the implicit policy:
151    /// - Empty scope → deny all (no rows updated)
152    /// - Tenants only → update only in specified tenants
153    /// - Resources only → update only specified resource IDs
154    /// - Both → AND them together
155    ///
156    #[must_use]
157    pub fn scope_with(self, scope: &AccessScope) -> SecureUpdateMany<E, Scoped> {
158        let cond = build_scope_condition::<E>(scope);
159        SecureUpdateMany {
160            inner: self.inner.filter(cond),
161            _state: PhantomData,
162        }
163    }
164}
165
166// Methods available only on Scoped updates
167impl<E> SecureUpdateMany<E, Scoped>
168where
169    E: EntityTrait,
170{
171    /// Execute the update operation.
172    ///
173    /// # Errors
174    /// Returns `ScopeError::Db` if the database operation fails.
175    #[allow(clippy::disallowed_methods)]
176    pub async fn exec<C: ConnectionTrait + Send + Sync>(
177        self,
178        conn: &C,
179    ) -> Result<sea_orm::UpdateResult, ScopeError> {
180        Ok(self.inner.exec(conn).await?)
181    }
182
183    /// Unwrap the inner `SeaORM` `UpdateMany` for advanced use cases.
184    ///
185    /// # Safety
186    /// The caller must ensure they don't remove or bypass the security
187    /// conditions that were applied during `.scope_with()`.
188    #[must_use]
189    pub fn into_inner(self) -> sea_orm::UpdateMany<E> {
190        self.inner
191    }
192}
193
194/// A type-safe wrapper around `SeaORM`'s `DeleteMany` that enforces scoping.
195///
196/// This wrapper uses the typestate pattern to ensure that delete operations
197/// cannot be executed without first applying access control via `.scope_with()`.
198///
199/// # Example
200/// ```ignore
201/// use modkit_db::secure::{AccessScope, SecureDeleteExt};
202///
203/// let scope = AccessScope::tenants_only(vec![tenant_id]);
204/// let result = user::Entity::delete_many()
205///     .filter(user::Column::Status.eq("inactive"))
206///     .secure()           // Returns SecureDeleteMany<E, Unscoped>
207///     .scope_with(&scope)? // Returns SecureDeleteMany<E, Scoped>
208///     .exec(conn)         // Now can execute
209///     .await?;
210/// ```
211#[derive(Clone, Debug)]
212pub struct SecureDeleteMany<E: EntityTrait, S> {
213    pub(crate) inner: sea_orm::DeleteMany<E>,
214    pub(crate) _state: PhantomData<S>,
215}
216
217/// Extension trait to convert a regular `SeaORM` `DeleteMany` into a `SecureDeleteMany`.
218pub trait SecureDeleteExt<E: EntityTrait>: Sized {
219    /// Convert this delete operation into a secure (unscoped) delete.
220    /// You must call `.scope_with()` before executing.
221    fn secure(self) -> SecureDeleteMany<E, Unscoped>;
222}
223
224impl<E> SecureDeleteExt<E> for sea_orm::DeleteMany<E>
225where
226    E: EntityTrait,
227{
228    fn secure(self) -> SecureDeleteMany<E, Unscoped> {
229        SecureDeleteMany {
230            inner: self,
231            _state: PhantomData,
232        }
233    }
234}
235
236// Methods available only on Unscoped deletes
237impl<E> SecureDeleteMany<E, Unscoped>
238where
239    E: ScopableEntity + EntityTrait,
240    E::Column: ColumnTrait + Copy,
241{
242    /// Apply access control scope to this delete, transitioning to the `Scoped` state.
243    ///
244    /// This applies the implicit policy:
245    /// - Empty scope → deny all (no rows deleted)
246    /// - Tenants only → delete only in specified tenants
247    /// - Resources only → delete only specified resource IDs
248    /// - Both → AND them together
249    ///
250    #[must_use]
251    pub fn scope_with(self, scope: &AccessScope) -> SecureDeleteMany<E, Scoped> {
252        let cond = build_scope_condition::<E>(scope);
253        SecureDeleteMany {
254            inner: self.inner.filter(cond),
255            _state: PhantomData,
256        }
257    }
258}
259
260// Methods available only on Scoped deletes
261impl<E> SecureDeleteMany<E, Scoped>
262where
263    E: EntityTrait,
264{
265    /// Add additional filters to the scoped delete.
266    /// The scope conditions remain in place.
267    #[must_use]
268    pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
269        self.inner = QueryFilter::filter(self.inner, filter);
270        self
271    }
272
273    /// Execute the delete operation.
274    ///
275    /// # Errors
276    /// Returns `ScopeError::Db` if the database operation fails.
277    #[allow(clippy::disallowed_methods)]
278    pub async fn exec<C: ConnectionTrait + Send + Sync>(
279        self,
280        conn: &C,
281    ) -> Result<sea_orm::DeleteResult, ScopeError> {
282        Ok(self.inner.exec(conn).await?)
283    }
284
285    /// Unwrap the inner `SeaORM` `DeleteMany` for advanced use cases.
286    ///
287    /// # Safety
288    /// The caller must ensure they don't remove or bypass the security
289    /// conditions that were applied during `.scope_with()`.
290    #[must_use]
291    pub fn into_inner(self) -> sea_orm::DeleteMany<E> {
292        self.inner
293    }
294}
295
296#[cfg(test)]
297#[cfg_attr(coverage_nightly, coverage(off))]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_validate_tenant_in_scope() {
303        let tenant_id = uuid::Uuid::new_v4();
304        let scope = crate::secure::AccessScope::tenants_only(vec![tenant_id]);
305
306        assert!(validate_tenant_in_scope(tenant_id, &scope).is_ok());
307
308        let other_id = uuid::Uuid::new_v4();
309        assert!(validate_tenant_in_scope(other_id, &scope).is_err());
310    }
311
312    // Note: Full integration tests with database require actual SeaORM entities
313    // These tests verify the typestate pattern compiles correctly
314
315    #[test]
316    fn test_typestate_compile_check() {
317        // This test verifies the typestate markers compile
318        let unscoped: PhantomData<Unscoped> = PhantomData;
319        let scoped: PhantomData<Scoped> = PhantomData;
320        // Use the variables to avoid unused warnings
321        let _ = (unscoped, scoped);
322    }
323}