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}