use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, InsertResult, IntoActiveModel, ModelTrait,
QueryFilter,
sea_query::{IntoIden, OnConflict, SimpleExpr},
};
use std::marker::PhantomData;
use crate::secure::cond::build_scope_condition;
use crate::secure::error::ScopeError;
use crate::secure::{
AccessScope, DBRunner, DBRunnerInternal, ScopableEntity, Scoped, SeaOrmRunner, SecureEntityExt,
Unscoped,
};
fn sea_value_to_scope_value(v: &sea_orm::Value) -> Option<modkit_security::ScopeValue> {
use modkit_security::ScopeValue;
match v {
sea_orm::Value::Uuid(Some(u)) => Some(ScopeValue::Uuid(**u)),
sea_orm::Value::String(Some(s)) => {
if let Ok(uuid) = uuid::Uuid::parse_str(s) {
Some(ScopeValue::Uuid(uuid))
} else {
Some(ScopeValue::String(s.to_string()))
}
}
sea_orm::Value::BigInt(Some(n)) => Some(ScopeValue::Int(*n)),
sea_orm::Value::Int(Some(n)) => Some(ScopeValue::Int(i64::from(*n))),
sea_orm::Value::SmallInt(Some(n)) => Some(ScopeValue::Int(i64::from(*n))),
sea_orm::Value::TinyInt(Some(n)) => Some(ScopeValue::Int(i64::from(*n))),
sea_orm::Value::Bool(Some(b)) => Some(ScopeValue::Bool(*b)),
_ => None,
}
}
fn validate_insert_scope<A>(am: &A, scope: &AccessScope) -> Result<(), ScopeError>
where
A: ActiveModelTrait,
A::Entity: ScopableEntity + EntityTrait,
<A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
{
if scope.is_unconstrained() || A::Entity::IS_UNRESTRICTED {
return Ok(());
}
if scope.is_deny_all() {
return Err(ScopeError::Denied(
"insert denied: scope has no constraints",
));
}
'next_constraint: for constraint in scope.constraints() {
for filter in constraint.filters() {
let Some(col) = <A::Entity as ScopableEntity>::resolve_property(filter.property())
else {
continue 'next_constraint;
};
match am.get(col) {
sea_orm::ActiveValue::NotSet => {
}
sea_orm::ActiveValue::Set(v) | sea_orm::ActiveValue::Unchanged(v) => {
let Some(sv) = sea_value_to_scope_value(&v) else {
continue 'next_constraint;
};
if !filter.values().contains(&sv) {
continue 'next_constraint;
}
}
}
}
return Ok(());
}
Err(ScopeError::Denied(
"insert denied: entity values do not satisfy any scope constraint",
))
}
pub async fn secure_insert<E>(
am: E::ActiveModel,
scope: &AccessScope,
runner: &impl DBRunner,
) -> Result<E::Model, ScopeError>
where
E: ScopableEntity + EntityTrait,
E::Column: ColumnTrait + Copy,
E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
E::Model: sea_orm::IntoActiveModel<E::ActiveModel>,
{
if let Some(tenant_col) = E::tenant_col()
&& let sea_orm::ActiveValue::NotSet = am.get(tenant_col)
{
return Err(ScopeError::Invalid("tenant_id is required"));
}
validate_insert_scope(&am, scope)?;
match DBRunnerInternal::as_seaorm(runner) {
SeaOrmRunner::Conn(db) => Ok(am.insert(db).await?),
SeaOrmRunner::Tx(tx) => Ok(am.insert(tx).await?),
}
}
pub async fn secure_update_with_scope<E>(
am: E::ActiveModel,
scope: &AccessScope,
id: uuid::Uuid,
runner: &impl DBRunner,
) -> Result<E::Model, ScopeError>
where
E: ScopableEntity + EntityTrait,
E::Column: ColumnTrait + Copy,
E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
E::Model: sea_orm::IntoActiveModel<E::ActiveModel> + sea_orm::ModelTrait<Entity = E>,
{
let existing = E::find()
.secure()
.scope_with(scope)
.and_id(id)?
.one(runner)
.await?;
let Some(existing) = existing else {
return Err(ScopeError::Denied(
"entity not found or not accessible in current security scope",
));
};
if let Some(tcol) = E::tenant_col() {
let stored = match existing.get(tcol) {
sea_orm::Value::Uuid(Some(u)) => *u,
_ => return Err(ScopeError::Invalid("tenant_id has unexpected type")),
};
let incoming = match am.get(tcol) {
sea_orm::ActiveValue::Set(v) | sea_orm::ActiveValue::Unchanged(v) => match v {
sea_orm::Value::Uuid(Some(u)) => Some(*u),
sea_orm::Value::Uuid(None) => {
return Err(ScopeError::Invalid("tenant_id is required"));
}
_ => {
return Err(ScopeError::Invalid("tenant_id has unexpected type"));
}
},
sea_orm::ActiveValue::NotSet => None,
};
if let Some(incoming) = incoming
&& incoming != stored
{
return Err(ScopeError::Denied("tenant_id is immutable"));
}
}
match DBRunnerInternal::as_seaorm(runner) {
SeaOrmRunner::Conn(db) => Ok(am.update(db).await?),
SeaOrmRunner::Tx(tx) => Ok(am.update(tx).await?),
}
}
pub fn validate_tenant_in_scope(
tenant_id: uuid::Uuid,
scope: &AccessScope,
) -> Result<(), ScopeError> {
if scope.is_unconstrained() {
return Ok(());
}
let prop = modkit_security::pep_properties::OWNER_TENANT_ID;
if !scope.has_property(prop) {
return Err(ScopeError::Denied(
"tenant scope required for tenant-scoped insert",
));
}
if scope.contains_uuid(prop, tenant_id) {
return Ok(());
}
Err(ScopeError::TenantNotInScope { tenant_id })
}
#[derive(Debug)]
pub struct SecureInsertOne<A, S>
where
A: ActiveModelTrait,
{
pub(crate) inner: sea_orm::Insert<A>,
pub(crate) _state: PhantomData<S>,
}
pub trait SecureInsertExt<A: ActiveModelTrait>: Sized {
fn secure(self) -> SecureInsertOne<A, Unscoped>;
}
impl<A> SecureInsertExt<A> for sea_orm::Insert<A>
where
A: ActiveModelTrait,
{
fn secure(self) -> SecureInsertOne<A, Unscoped> {
SecureInsertOne {
inner: self,
_state: PhantomData,
}
}
}
impl<A> SecureInsertOne<A, Unscoped>
where
A: ActiveModelTrait + Send,
A::Entity: ScopableEntity + EntityTrait,
<A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
{
pub fn scope_unchecked(
self,
scope: &AccessScope,
) -> Result<SecureInsertOne<A, Scoped>, ScopeError> {
let _ = scope;
Ok(SecureInsertOne {
inner: self.inner,
_state: PhantomData,
})
}
pub fn scope_with_model(
self,
scope: &AccessScope,
am: &A,
) -> Result<SecureInsertOne<A, Scoped>, ScopeError> {
validate_insert_scope(am, scope)?;
Ok(SecureInsertOne {
inner: self.inner,
_state: PhantomData,
})
}
}
impl<A> SecureInsertOne<A, Scoped>
where
A: ActiveModelTrait,
A::Entity: ScopableEntity + EntityTrait,
<A::Entity as EntityTrait>::Column: ColumnTrait + Copy,
{
#[must_use]
pub fn on_conflict(mut self, on_conflict: SecureOnConflict<A::Entity>) -> Self {
self.inner = self.inner.on_conflict(on_conflict.build());
self
}
#[must_use]
pub fn on_conflict_raw(mut self, on_conflict: OnConflict) -> Self {
self.inner = self.inner.on_conflict(on_conflict);
self
}
}
impl<A> SecureInsertOne<A, Scoped>
where
A: ActiveModelTrait,
{
#[allow(clippy::disallowed_methods)]
pub async fn exec<C>(self, runner: &C) -> Result<InsertResult<A>, ScopeError>
where
C: DBRunner,
A: Send,
{
match DBRunnerInternal::as_seaorm(runner) {
SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
}
}
#[allow(clippy::disallowed_methods)]
pub async fn exec_with_returning<C>(
self,
runner: &C,
) -> Result<<A::Entity as EntityTrait>::Model, ScopeError>
where
C: DBRunner,
A: Send,
<A::Entity as EntityTrait>::Model: IntoActiveModel<A>,
{
match DBRunnerInternal::as_seaorm(runner) {
SeaOrmRunner::Conn(db) => Ok(self.inner.exec_with_returning(db).await?),
SeaOrmRunner::Tx(tx) => Ok(self.inner.exec_with_returning(tx).await?),
}
}
#[must_use]
pub fn into_inner(self) -> sea_orm::Insert<A> {
self.inner
}
}
#[derive(Debug, Clone)]
pub struct SecureOnConflict<E: EntityTrait> {
inner: OnConflict,
_entity: PhantomData<E>,
}
impl<E> SecureOnConflict<E>
where
E: ScopableEntity + EntityTrait,
E::Column: ColumnTrait + Copy,
{
#[must_use]
pub fn columns<C, I>(cols: I) -> Self
where
C: IntoIden,
I: IntoIterator<Item = C>,
{
Self {
inner: OnConflict::columns(cols),
_entity: PhantomData,
}
}
pub fn update_columns<C, I>(mut self, cols: I) -> Result<Self, ScopeError>
where
C: IntoIden + Copy + 'static,
I: IntoIterator<Item = C>,
{
let cols: Vec<C> = cols.into_iter().collect();
if let Some(tenant_col) = E::tenant_col() {
let tenant_iden = tenant_col.into_iden();
for col in &cols {
let col_iden = col.into_iden();
if col_iden.to_string() == tenant_iden.to_string() {
return Err(ScopeError::Denied("tenant_id is immutable"));
}
}
}
self.inner.update_columns(cols);
Ok(self)
}
pub fn value<C>(mut self, col: C, expr: SimpleExpr) -> Result<Self, ScopeError>
where
C: IntoIden + Copy + 'static,
{
if let Some(tenant_col) = E::tenant_col() {
let tenant_iden = tenant_col.into_iden();
let col_iden = col.into_iden();
if col_iden.to_string() == tenant_iden.to_string() {
return Err(ScopeError::Denied("tenant_id is immutable"));
}
}
self.inner.value(col, expr);
Ok(self)
}
#[must_use]
pub fn build(self) -> OnConflict {
self.inner
}
#[must_use]
pub fn inner_mut(&mut self) -> &mut OnConflict {
&mut self.inner
}
}
#[derive(Clone, Debug)]
pub struct SecureUpdateMany<E: EntityTrait, S> {
pub(crate) inner: sea_orm::UpdateMany<E>,
pub(crate) _state: PhantomData<S>,
pub(crate) tenant_update_attempted: bool,
}
impl<E, S> SecureUpdateMany<E, S>
where
E: ScopableEntity + EntityTrait,
E::Column: ColumnTrait + Copy,
{
#[must_use]
pub fn col_expr(mut self, col: E::Column, expr: sea_orm::sea_query::SimpleExpr) -> Self {
if let Some(tcol) = E::tenant_col()
&& std::mem::discriminant(&col) == std::mem::discriminant(&tcol)
{
self.tenant_update_attempted = true;
}
self.inner = self.inner.col_expr(col, expr);
self
}
#[must_use]
pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
self.inner = QueryFilter::filter(self.inner, filter);
self
}
}
pub trait SecureUpdateExt<E: EntityTrait>: Sized {
fn secure(self) -> SecureUpdateMany<E, Unscoped>;
}
impl<E> SecureUpdateExt<E> for sea_orm::UpdateMany<E>
where
E: EntityTrait,
{
fn secure(self) -> SecureUpdateMany<E, Unscoped> {
SecureUpdateMany {
inner: self,
_state: PhantomData,
tenant_update_attempted: false,
}
}
}
impl<E> SecureUpdateMany<E, Unscoped>
where
E: ScopableEntity + EntityTrait,
E::Column: ColumnTrait + Copy,
{
#[must_use]
pub fn scope_with(self, scope: &AccessScope) -> SecureUpdateMany<E, Scoped> {
let cond = build_scope_condition::<E>(scope);
SecureUpdateMany {
inner: self.inner.filter(cond),
_state: PhantomData,
tenant_update_attempted: self.tenant_update_attempted,
}
}
}
impl<E> SecureUpdateMany<E, Scoped>
where
E: EntityTrait,
{
#[allow(clippy::disallowed_methods)]
pub async fn exec(self, runner: &impl DBRunner) -> Result<sea_orm::UpdateResult, ScopeError> {
if self.tenant_update_attempted {
return Err(ScopeError::Denied("tenant_id is immutable"));
}
match DBRunnerInternal::as_seaorm(runner) {
SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
}
}
#[must_use]
pub fn into_inner(self) -> sea_orm::UpdateMany<E> {
self.inner
}
}
#[derive(Clone, Debug)]
pub struct SecureDeleteMany<E: EntityTrait, S> {
pub(crate) inner: sea_orm::DeleteMany<E>,
pub(crate) _state: PhantomData<S>,
}
pub trait SecureDeleteExt<E: EntityTrait>: Sized {
fn secure(self) -> SecureDeleteMany<E, Unscoped>;
}
impl<E> SecureDeleteExt<E> for sea_orm::DeleteMany<E>
where
E: EntityTrait,
{
fn secure(self) -> SecureDeleteMany<E, Unscoped> {
SecureDeleteMany {
inner: self,
_state: PhantomData,
}
}
}
impl<E> SecureDeleteMany<E, Unscoped>
where
E: ScopableEntity + EntityTrait,
E::Column: ColumnTrait + Copy,
{
#[must_use]
pub fn scope_with(self, scope: &AccessScope) -> SecureDeleteMany<E, Scoped> {
let cond = build_scope_condition::<E>(scope);
SecureDeleteMany {
inner: self.inner.filter(cond),
_state: PhantomData,
}
}
}
impl<E> SecureDeleteMany<E, Scoped>
where
E: EntityTrait,
{
#[must_use]
pub fn filter(mut self, filter: sea_orm::Condition) -> Self {
self.inner = QueryFilter::filter(self.inner, filter);
self
}
#[allow(clippy::disallowed_methods)]
pub async fn exec(self, runner: &impl DBRunner) -> Result<sea_orm::DeleteResult, ScopeError> {
match DBRunnerInternal::as_seaorm(runner) {
SeaOrmRunner::Conn(db) => Ok(self.inner.exec(db).await?),
SeaOrmRunner::Tx(tx) => Ok(self.inner.exec(tx).await?),
}
}
#[must_use]
pub fn into_inner(self) -> sea_orm::DeleteMany<E> {
self.inner
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use sea_orm::entity::prelude::*;
mod test_entity {
use super::*;
use modkit_security::pep_properties;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "test_table")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub value: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl ScopableEntity for Entity {
fn tenant_col() -> Option<Column> {
Some(Column::TenantId)
}
fn resource_col() -> Option<Column> {
Some(Column::Id)
}
fn owner_col() -> Option<Column> {
None
}
fn type_col() -> Option<Column> {
None
}
fn resolve_property(property: &str) -> Option<Column> {
match property {
pep_properties::OWNER_TENANT_ID => Self::tenant_col(),
pep_properties::RESOURCE_ID => Self::resource_col(),
_ => None,
}
}
}
}
mod global_entity {
use super::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "global_table")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub config_key: String,
pub config_value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl ScopableEntity for Entity {
fn tenant_col() -> Option<Column> {
None }
fn resource_col() -> Option<Column> {
Some(Column::Id)
}
fn owner_col() -> Option<Column> {
None
}
fn type_col() -> Option<Column> {
None
}
fn resolve_property(property: &str) -> Option<Column> {
match property {
"id" => Self::resource_col(),
_ => None,
}
}
}
}
#[test]
fn test_validate_tenant_in_scope() {
let tenant_id = uuid::Uuid::new_v4();
let scope = crate::secure::AccessScope::for_tenants(vec![tenant_id]);
assert!(validate_tenant_in_scope(tenant_id, &scope).is_ok());
let other_id = uuid::Uuid::new_v4();
assert!(validate_tenant_in_scope(other_id, &scope).is_err());
}
#[test]
fn test_typestate_compile_check() {
let unscoped: PhantomData<Unscoped> = PhantomData;
let scoped: PhantomData<Scoped> = PhantomData;
let _ = (unscoped, scoped);
}
#[test]
fn test_tenant_not_in_scope_returns_error() {
let allowed_tenant = uuid::Uuid::new_v4();
let disallowed_tenant = uuid::Uuid::new_v4();
let scope = crate::secure::AccessScope::for_tenants(vec![allowed_tenant]);
assert!(validate_tenant_in_scope(allowed_tenant, &scope).is_ok());
let result = validate_tenant_in_scope(disallowed_tenant, &scope);
assert!(result.is_err());
match result {
Err(ScopeError::TenantNotInScope { tenant_id }) => {
assert_eq!(tenant_id, disallowed_tenant);
}
_ => panic!("Expected TenantNotInScope error"),
}
}
#[test]
fn test_empty_scope_denied_for_tenant_scoped() {
let tenant_id = uuid::Uuid::new_v4();
let empty_scope = crate::secure::AccessScope::default();
let result = validate_tenant_in_scope(tenant_id, &empty_scope);
assert!(result.is_err());
match result {
Err(ScopeError::Denied(_)) => {}
_ => panic!("Expected Denied error for empty scope"),
}
}
#[test]
fn test_secure_on_conflict_update_columns_allows_non_tenant_columns() {
use test_entity::{Column, Entity};
let result = SecureOnConflict::<Entity>::columns([Column::Id])
.update_columns([Column::Name, Column::Value]);
assert!(result.is_ok());
}
#[test]
fn test_secure_on_conflict_update_columns_rejects_tenant_column() {
use test_entity::{Column, Entity};
let result = SecureOnConflict::<Entity>::columns([Column::Id]).update_columns([
Column::Name,
Column::TenantId,
Column::Value,
]);
assert!(result.is_err());
match result {
Err(ScopeError::Denied(msg)) => {
assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
}
_ => panic!("Expected Denied error for tenant_id in update_columns"),
}
}
#[test]
fn test_secure_on_conflict_value_allows_non_tenant_columns() {
use sea_orm::sea_query::Expr;
use test_entity::{Column, Entity};
let result = SecureOnConflict::<Entity>::columns([Column::Id])
.value(Column::Name, Expr::value("test"));
assert!(result.is_ok());
}
#[test]
fn test_secure_on_conflict_value_rejects_tenant_column() {
use sea_orm::sea_query::Expr;
use test_entity::{Column, Entity};
let result = SecureOnConflict::<Entity>::columns([Column::Id])
.value(Column::TenantId, Expr::value(uuid::Uuid::new_v4()));
assert!(result.is_err());
match result {
Err(ScopeError::Denied(msg)) => {
assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
}
_ => panic!("Expected Denied error for tenant_id in value()"),
}
}
#[test]
fn test_secure_on_conflict_chained_value_rejects_tenant_column() {
use sea_orm::sea_query::Expr;
use test_entity::{Column, Entity};
let result = SecureOnConflict::<Entity>::columns([Column::Id])
.value(Column::Name, Expr::value("test"))
.and_then(|c| c.value(Column::TenantId, Expr::value(uuid::Uuid::new_v4())));
assert!(result.is_err());
match result {
Err(ScopeError::Denied(msg)) => {
assert!(msg.contains("immutable"), "Expected immutable error: {msg}");
}
_ => panic!("Expected Denied error for tenant_id in chained value()"),
}
}
#[test]
fn test_secure_on_conflict_global_entity_allows_all_columns() {
use global_entity::{Column, Entity};
let result = SecureOnConflict::<Entity>::columns([Column::Id])
.update_columns([Column::ConfigKey, Column::ConfigValue]);
assert!(result.is_ok());
}
#[test]
fn test_secure_on_conflict_build_produces_on_conflict() {
use test_entity::{Column, Entity};
let on_conflict = SecureOnConflict::<Entity>::columns([Column::Id])
.update_columns([Column::Name, Column::Value])
.expect("should succeed")
.build();
_ = format!("{on_conflict:?}");
}
mod owner_entity {
use super::*;
use modkit_security::pep_properties;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "addresses")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub city_id: Uuid,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl ScopableEntity for Entity {
fn tenant_col() -> Option<Column> {
Some(Column::TenantId)
}
fn resource_col() -> Option<Column> {
Some(Column::Id)
}
fn owner_col() -> Option<Column> {
Some(Column::UserId)
}
fn type_col() -> Option<Column> {
None
}
fn resolve_property(property: &str) -> Option<Column> {
match property {
pep_properties::OWNER_TENANT_ID => Some(Column::TenantId),
pep_properties::RESOURCE_ID => Some(Column::Id),
pep_properties::OWNER_ID => Some(Column::UserId),
"city_id" => Some(Column::CityId),
_ => None,
}
}
}
}
#[test]
fn test_validate_insert_scope_allow_all_passes() {
use owner_entity::ActiveModel;
use sea_orm::Set;
let scope = crate::secure::AccessScope::allow_all();
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(Uuid::new_v4()),
user_id: Set(Uuid::new_v4()),
city_id: Set(Uuid::new_v4()),
};
assert!(validate_insert_scope(&am, &scope).is_ok());
}
#[test]
fn test_validate_insert_scope_deny_all_rejects() {
use owner_entity::ActiveModel;
use sea_orm::Set;
let scope = crate::secure::AccessScope::deny_all();
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(Uuid::new_v4()),
user_id: Set(Uuid::new_v4()),
city_id: Set(Uuid::new_v4()),
};
assert!(validate_insert_scope(&am, &scope).is_err());
}
#[test]
fn test_validate_insert_scope_tenant_only_matches() {
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let scope = crate::secure::AccessScope::for_tenant(tenant_id);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(Uuid::new_v4()),
city_id: Set(Uuid::new_v4()),
};
assert!(validate_insert_scope(&am, &scope).is_ok());
}
#[test]
fn test_validate_insert_scope_tenant_mismatch_rejects() {
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let other_tenant = Uuid::new_v4();
let scope = crate::secure::AccessScope::for_tenant(tenant_id);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(other_tenant),
user_id: Set(Uuid::new_v4()),
city_id: Set(Uuid::new_v4()),
};
assert!(validate_insert_scope(&am, &scope).is_err());
}
#[test]
fn test_validate_insert_scope_owner_id_matches() {
use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
use modkit_security::pep_properties;
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let city_id = Uuid::new_v4();
let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
ScopeFilter::eq(pep_properties::OWNER_ID, user_id),
ScopeFilter::eq("city_id", city_id),
])]);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
city_id: Set(city_id),
};
assert!(
validate_insert_scope(&am, &scope).is_ok(),
"Insert should pass when all properties match"
);
}
#[test]
fn test_validate_insert_scope_owner_id_mismatch_rejects() {
use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
use modkit_security::pep_properties;
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let user_a = Uuid::new_v4();
let user_b = Uuid::new_v4();
let city_id = Uuid::new_v4();
let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
ScopeFilter::eq(pep_properties::OWNER_ID, user_a),
ScopeFilter::eq("city_id", city_id),
])]);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(user_b),
city_id: Set(city_id),
};
assert!(
validate_insert_scope(&am, &scope).is_err(),
"Insert must be rejected when owner_id doesn't match"
);
}
#[test]
fn test_validate_insert_scope_city_id_mismatch_rejects() {
use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
use modkit_security::pep_properties;
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let allowed_city = Uuid::new_v4();
let disallowed_city = Uuid::new_v4();
let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
ScopeFilter::eq(pep_properties::OWNER_ID, user_id),
ScopeFilter::eq("city_id", allowed_city),
])]);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
city_id: Set(disallowed_city),
};
assert!(
validate_insert_scope(&am, &scope).is_err(),
"Insert must be rejected when city_id doesn't match"
);
}
#[test]
fn test_validate_insert_scope_or_semantics() {
use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
use modkit_security::pep_properties;
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let city_1 = Uuid::new_v4();
let city_2 = Uuid::new_v4();
let scope = AccessScope::from_constraints(vec![
ScopeConstraint::new(vec![
ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
ScopeFilter::eq("city_id", city_1),
]),
ScopeConstraint::new(vec![
ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
ScopeFilter::eq("city_id", city_2),
]),
]);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
city_id: Set(city_2),
};
assert!(
validate_insert_scope(&am, &scope).is_ok(),
"Insert should pass when matching any constraint (OR semantics)"
);
let city_3 = Uuid::new_v4();
let am_bad = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
city_id: Set(city_3),
};
assert!(
validate_insert_scope(&am_bad, &scope).is_err(),
"Insert must be rejected when no constraint matches"
);
}
#[test]
fn test_validate_insert_scope_unknown_property_fails_closed() {
use modkit_security::access_scope::{ScopeConstraint, ScopeFilter};
use modkit_security::pep_properties;
use owner_entity::ActiveModel;
use sea_orm::Set;
let tenant_id = Uuid::new_v4();
let scope = AccessScope::from_constraints(vec![ScopeConstraint::new(vec![
ScopeFilter::in_uuids(pep_properties::OWNER_TENANT_ID, vec![tenant_id]),
ScopeFilter::eq("nonexistent_prop", Uuid::new_v4()),
])]);
let am = ActiveModel {
id: Set(Uuid::new_v4()),
tenant_id: Set(tenant_id),
user_id: Set(Uuid::new_v4()),
city_id: Set(Uuid::new_v4()),
};
assert!(
validate_insert_scope(&am, &scope).is_err(),
"Unknown property must cause constraint to fail (fail-closed)"
);
}
}