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
15fn 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 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
39fn 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 'next_constraint: for constraint in scope.constraints() {
79 for filter in constraint.filters() {
81 let Some(col) = <A::Entity as ScopableEntity>::resolve_property(filter.property())
82 else {
83 continue 'next_constraint;
85 };
86
87 match am.get(col) {
89 sea_orm::ActiveValue::NotSet => {
90 }
93 sea_orm::ActiveValue::Set(v) | sea_orm::ActiveValue::Unchanged(v) => {
94 let Some(sv) = sea_value_to_scope_value(&v) else {
95 continue 'next_constraint;
97 };
98
99 if !filter.values().contains(&sv) {
100 continue 'next_constraint;
101 }
102 }
103 }
104 }
105 return Ok(());
107 }
108
109 Err(ScopeError::Denied(
110 "insert denied: entity values do not satisfy any scope constraint",
111 ))
112}
113
114pub 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 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
205pub 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
271pub 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#[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
337pub trait SecureInsertExt<A: ActiveModelTrait>: Sized {
339 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
356impl<A> SecureInsertOne<A, Unscoped>
358where
359 A: ActiveModelTrait + Send,
360 A::Entity: ScopableEntity + EntityTrait,
361 <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
362{
363 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 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
409impl<A> SecureInsertOne<A, Scoped>
411where
412 A: ActiveModelTrait,
413 A::Entity: ScopableEntity + EntityTrait,
414 <A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
415{
416 #[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 #[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
454impl<A> SecureInsertOne<A, Scoped>
456where
457 A: ActiveModelTrait,
458{
459 #[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 #[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 #[must_use]
504 pub fn into_inner(self) -> sea_orm::Insert<A> {
505 self.inner
506 }
507}
508
509#[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 #[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 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 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 pub fn value<C>(mut self, col: C, expr: SimpleExpr) -> Result<Self, ScopeError>
617 where
618 C: IntoIden + Copy + 'static,
619 {
620 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 #[must_use]
637 pub fn build(self) -> OnConflict {
638 self.inner
639 }
640
641 #[must_use]
649 pub fn inner_mut(&mut self) -> &mut OnConflict {
650 &mut self.inner
651 }
652}
653
654#[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
678impl<E, S> SecureUpdateMany<E, S>
680where
681 E: ScopableEntity + EntityTrait,
682 E::Column: ColumnTrait + Copy,
683{
684 #[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 #[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
704pub trait SecureUpdateExt<E: EntityTrait>: Sized {
706 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
724impl<E> SecureUpdateMany<E, Unscoped>
726where
727 E: ScopableEntity + EntityTrait,
728 E::Column: ColumnTrait + Copy,
729{
730 #[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
749impl<E> SecureUpdateMany<E, Scoped>
751where
752 E: EntityTrait,
753{
754 #[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 #[must_use]
775 pub fn into_inner(self) -> sea_orm::UpdateMany<E> {
776 self.inner
777 }
778}
779
780#[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
803pub trait SecureDeleteExt<E: EntityTrait>: Sized {
805 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
822impl<E> SecureDeleteMany<E, Unscoped>
824where
825 E: ScopableEntity + EntityTrait,
826 E::Column: ColumnTrait + Copy,
827{
828 #[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
846impl<E> SecureDeleteMany<E, Scoped>
848where
849 E: EntityTrait,
850{
851 #[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 #[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 #[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 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 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 }
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 #[test]
986 fn test_typestate_compile_check() {
987 let unscoped: PhantomData<Unscoped> = PhantomData;
989 let scoped: PhantomData<Scoped> = PhantomData;
990 let _ = (unscoped, scoped);
992 }
993
994 #[test]
995 fn test_tenant_not_in_scope_returns_error() {
996 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 assert!(validate_tenant_in_scope(allowed_tenant, &scope).is_ok());
1003
1004 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 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 #[test]
1032 fn test_secure_on_conflict_update_columns_allows_non_tenant_columns() {
1033 use test_entity::{Column, Entity};
1034
1035 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 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 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 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 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 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 let on_conflict = SecureOnConflict::<Entity>::columns([Column::Id])
1128 .update_columns([Column::Name, Column::Value])
1129 .expect("should succeed")
1130 .build();
1131
1132 _ = format!("{on_conflict:?}");
1135 }
1136
1137 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 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 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 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 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 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 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 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 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 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}