fastapi-basekit 0.5.3__tar.gz → 0.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/PKG-INFO +1 -1
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/repository/base.py +113 -71
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/service/base.py +39 -3
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/repository/base.py +34 -9
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/service/base.py +10 -3
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/PKG-INFO +1 -1
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/SOURCES.txt +1 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/pyproject.toml +1 -1
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_service_regression.py +73 -0
- fastapi_basekit-0.5.4/tests/test_owner_filter_helper.py +65 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlalchemy_service_regression.py +73 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/LICENSE +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/README.md +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/permissions/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/permissions/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/cli/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/cli/main.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/domain.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/handler.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/openapi.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/py.typed +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/jwt.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/schema.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/servicios/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/entry_points.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/requires.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/top_level.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/setup.cfg +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_action_param_leak.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_api_exceptions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_base_response.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_base_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_aggregation_hooks.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_aggregation_integration.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_keyset_pagination.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_repository_regression.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_service_mutable_defaults.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_controller_auto_permissions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_crud_beanie_controller.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_crud_controller.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_crud_sqlmodel_repository_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_exception_handlers.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_filter_operators.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_format_response_schema_override.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_generics_optional_and_cache.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_generics_wiring.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_jwt_config_robustness.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_jwt_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_openapi_simplify.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_classification.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_coercion_matrix.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_coercion_unit.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_http_matrix_beanie.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_http_matrix_sqlalchemy.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_rich_type_coercion.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_path_filter_mixin_compat.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_post_process_list_hook.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_pulbot_cbv_pattern_beanie.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_repo_signature_aliases.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sql_queryset_override.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlalchemy_base_service_order.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlalchemy_repository_regression.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlmodel_filter_operators.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_typed_filter_coercion.py +0 -0
{fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/repository/base.py
RENAMED
|
@@ -119,75 +119,60 @@ class BaseRepository(Generic[ModelT]):
|
|
|
119
119
|
kwargs["projection"] = projection
|
|
120
120
|
return kwargs
|
|
121
121
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
search: Optional[str],
|
|
125
|
-
search_fields: List[str],
|
|
126
|
-
filters: dict = None,
|
|
127
|
-
order_by: Optional[List[tuple]] = None,
|
|
128
|
-
**kwargs,
|
|
129
|
-
) -> FindMany[Document]:
|
|
130
|
-
"""Versión personalizada que soporta campos Link."""
|
|
131
|
-
exprs = []
|
|
132
|
-
|
|
133
|
-
if search and search_fields:
|
|
134
|
-
exprs.append(
|
|
135
|
-
Or(
|
|
136
|
-
*[
|
|
137
|
-
RegEx(
|
|
138
|
-
getattr(self.model, f),
|
|
139
|
-
f".*{search}.*",
|
|
140
|
-
options="i",
|
|
141
|
-
)
|
|
142
|
-
for f in search_fields
|
|
143
|
-
]
|
|
144
|
-
)
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
# Obtener campos del modelo
|
|
122
|
+
def _is_link_field(self, field_name: str) -> bool:
|
|
123
|
+
"""Verifica si un campo del modelo es de tipo Link (o Optional[Link])."""
|
|
148
124
|
model_fields = (
|
|
149
125
|
self.model.model_fields
|
|
150
126
|
if hasattr(self.model, "model_fields")
|
|
151
127
|
else {}
|
|
152
128
|
)
|
|
129
|
+
field_info = model_fields.get(field_name)
|
|
130
|
+
if not field_info:
|
|
131
|
+
return False
|
|
153
132
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
field_info = model_fields.get(field_name)
|
|
157
|
-
if not field_info:
|
|
158
|
-
return False
|
|
159
|
-
|
|
160
|
-
field_type = field_info.annotation
|
|
161
|
-
origin = get_origin(field_type)
|
|
162
|
-
|
|
163
|
-
# Caso directo: Link[Model]
|
|
164
|
-
if origin is Link:
|
|
165
|
-
return True
|
|
166
|
-
|
|
167
|
-
# Caso Optional[Link[Model]] = Union[Link[Model], None]
|
|
168
|
-
# O cualquier Union que contenga Link
|
|
169
|
-
if origin is not None:
|
|
170
|
-
args = get_args(field_type)
|
|
171
|
-
for arg in args:
|
|
172
|
-
# Verificar si el argumento es Link
|
|
173
|
-
arg_origin = get_origin(arg)
|
|
174
|
-
if arg_origin is Link:
|
|
175
|
-
return True
|
|
133
|
+
field_type = field_info.annotation
|
|
134
|
+
origin = get_origin(field_type)
|
|
176
135
|
|
|
177
|
-
|
|
136
|
+
# Caso directo: Link[Model]
|
|
137
|
+
if origin is Link:
|
|
138
|
+
return True
|
|
178
139
|
|
|
179
|
-
|
|
140
|
+
# Caso Optional[Link[Model]] = Union[Link[Model], None]
|
|
141
|
+
# O cualquier Union que contenga Link
|
|
142
|
+
if origin is not None:
|
|
143
|
+
args = get_args(field_type)
|
|
144
|
+
for arg in args:
|
|
145
|
+
arg_origin = get_origin(arg)
|
|
146
|
+
if arg_origin is Link:
|
|
147
|
+
return True
|
|
180
148
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return ObjectId(value)
|
|
188
|
-
except Exception:
|
|
189
|
-
return value
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _coerce_objectid(value: Any) -> Any:
|
|
153
|
+
"""str/UUID → ObjectId para queries Mongo sobre Link.id."""
|
|
154
|
+
if isinstance(value, ObjectId):
|
|
190
155
|
return value
|
|
156
|
+
if isinstance(value, str):
|
|
157
|
+
try:
|
|
158
|
+
return ObjectId(value)
|
|
159
|
+
except Exception:
|
|
160
|
+
return value
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
def _resolve_filter_query_args(self, filters: dict = None) -> list:
|
|
164
|
+
"""Traduce un dict de `get_filters()` a los args posicionales que
|
|
165
|
+
acepta `Model.find()`/`Model.find_one()` — resuelve campos Link
|
|
166
|
+
(directos y el alias `<field>_id`), pasa claves Mongo crudas
|
|
167
|
+
(`"user.$id"`, `"$or"`, ...) como dict, y avisa (no falla en
|
|
168
|
+
silencio) si una clave no resuelve a nada del modelo.
|
|
169
|
+
|
|
170
|
+
Compartido por `build_filter_query` (listados) y `get_by_id`
|
|
171
|
+
(retrieve/update/delete escopeados) — mismo motor de resolución de
|
|
172
|
+
filtros para las dos rutas, así un `get_filters()` de seguridad
|
|
173
|
+
protege ambas por igual."""
|
|
174
|
+
exprs: list = []
|
|
175
|
+
raw_filters: Dict[str, Any] = {}
|
|
191
176
|
|
|
192
177
|
for k, v in (filters or {}).items():
|
|
193
178
|
# MongoDB-style keys (dot-notation like "user.$id" or operators like "$or")
|
|
@@ -198,8 +183,8 @@ class BaseRepository(Generic[ModelT]):
|
|
|
198
183
|
|
|
199
184
|
if hasattr(self.model, k):
|
|
200
185
|
field_attr = getattr(self.model, k)
|
|
201
|
-
if _is_link_field(k):
|
|
202
|
-
exprs.append(field_attr.id == _coerce_objectid(v))
|
|
186
|
+
if self._is_link_field(k):
|
|
187
|
+
exprs.append(field_attr.id == self._coerce_objectid(v))
|
|
203
188
|
else:
|
|
204
189
|
exprs.append(field_attr == v)
|
|
205
190
|
continue
|
|
@@ -210,32 +195,59 @@ class BaseRepository(Generic[ModelT]):
|
|
|
210
195
|
# el modelo y es un Link[X].
|
|
211
196
|
if k.endswith("_id"):
|
|
212
197
|
base = k[:-3]
|
|
213
|
-
if hasattr(self.model, base) and _is_link_field(base):
|
|
198
|
+
if hasattr(self.model, base) and self._is_link_field(base):
|
|
214
199
|
field_attr = getattr(self.model, base)
|
|
215
|
-
exprs.append(field_attr.id == _coerce_objectid(v))
|
|
200
|
+
exprs.append(field_attr.id == self._coerce_objectid(v))
|
|
216
201
|
continue
|
|
217
202
|
|
|
218
203
|
# Clave no resoluble (typo, campo inexistente, nesting mal escrito).
|
|
219
204
|
# Se descarta — pero se AVISA: si la clave venía de `get_filters`
|
|
220
205
|
# (scoping por tenant/owner), su desaparición silenciosa deja el
|
|
221
|
-
# listado
|
|
222
|
-
#
|
|
206
|
+
# listado/retrieve SIN ese filtro. El warning hace visible la
|
|
207
|
+
# pérdida en logs/Sentry en vez de fallar en silencio.
|
|
223
208
|
logger.warning(
|
|
224
|
-
"
|
|
225
|
-
"ningún campo de %s). Si era un filtro de
|
|
226
|
-
"queda SIN ese filtro.",
|
|
209
|
+
"_resolve_filter_query_args: filtro '%s' descartado (no "
|
|
210
|
+
"resuelve a ningún campo de %s). Si era un filtro de "
|
|
211
|
+
"scoping, la query queda SIN ese filtro.",
|
|
227
212
|
k,
|
|
228
213
|
getattr(self.model, "__name__", self.model),
|
|
229
214
|
)
|
|
230
215
|
|
|
231
216
|
# Raw MongoDB filters go first so Beanie processes them as a dict condition
|
|
232
|
-
|
|
217
|
+
return ([raw_filters] if raw_filters else []) + exprs
|
|
218
|
+
|
|
219
|
+
def build_filter_query(
|
|
220
|
+
self,
|
|
221
|
+
search: Optional[str],
|
|
222
|
+
search_fields: List[str],
|
|
223
|
+
filters: dict = None,
|
|
224
|
+
order_by: Optional[List[tuple]] = None,
|
|
225
|
+
**kwargs,
|
|
226
|
+
) -> FindMany[Document]:
|
|
227
|
+
"""Versión personalizada que soporta campos Link."""
|
|
228
|
+
exprs = []
|
|
229
|
+
|
|
230
|
+
if search and search_fields:
|
|
231
|
+
exprs.append(
|
|
232
|
+
Or(
|
|
233
|
+
*[
|
|
234
|
+
RegEx(
|
|
235
|
+
getattr(self.model, f),
|
|
236
|
+
f".*{search}.*",
|
|
237
|
+
options="i",
|
|
238
|
+
)
|
|
239
|
+
for f in search_fields
|
|
240
|
+
]
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
query_args: list = exprs + self._resolve_filter_query_args(filters)
|
|
233
245
|
query = self.model.find(*query_args, **self._get_query_kwargs(**kwargs))
|
|
234
|
-
|
|
246
|
+
|
|
235
247
|
# Apply ordering if provided
|
|
236
248
|
if order_by:
|
|
237
249
|
query = query.sort(order_by)
|
|
238
|
-
|
|
250
|
+
|
|
239
251
|
return query
|
|
240
252
|
|
|
241
253
|
async def paginate(
|
|
@@ -521,10 +533,40 @@ class BaseRepository(Generic[ModelT]):
|
|
|
521
533
|
async def get_by_id(
|
|
522
534
|
self,
|
|
523
535
|
obj_id: Union[str, ObjectId],
|
|
536
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
524
537
|
**kwargs,
|
|
525
538
|
) -> Optional[ModelT]:
|
|
539
|
+
"""`filters` (opcional) — scoping de seguridad (típicamente
|
|
540
|
+
`Service.get_filters()`, ej. `{"user.$id": request.state.user.id}`).
|
|
541
|
+
Sin `filters`, comportamiento idéntico a antes (busca por id, sin
|
|
542
|
+
scope) — 100% retrocompatible. Con `filters`, un id que existe pero
|
|
543
|
+
no matchea el scope devuelve `None` (mismo resultado que "no
|
|
544
|
+
encontrado"), cerrando la clase de IDOR de "conozco el id de otro
|
|
545
|
+
tenant y lo pido directo".
|
|
546
|
+
|
|
547
|
+
El chequeo de scope corre SIEMPRE con `fetch_links=False` (find_one
|
|
548
|
+
plano, sobre el documento crudo — DBRef sin resolver), sea cual sea
|
|
549
|
+
el `fetch_links` que pida el caller para el fetch final. Motivo:
|
|
550
|
+
Beanie reescribe `find(..., fetch_links=True)` a una aggregation con
|
|
551
|
+
`$lookup`, y un filtro con notación de punto (`"user.$id"`,
|
|
552
|
+
`"user._id"`, etc.) puede matchear distinto — o no matchear nada —
|
|
553
|
+
según si corre ANTES o DESPUÉS del `$lookup` (mismo bug ya
|
|
554
|
+
documentado en `find_all_production_for_user` de pulbot-backend:
|
|
555
|
+
"fetch_links=True puede descartar resultados silenciosamente al
|
|
556
|
+
filtrar por campos dict anidados"). Combinar scope-filter +
|
|
557
|
+
fetch_links en una sola query es frágil; separarlos lo hace
|
|
558
|
+
determinístico sin importar qué `get_filters()` use cada service."""
|
|
526
559
|
if not isinstance(obj_id, ObjectId):
|
|
527
560
|
obj_id = ObjectId(obj_id)
|
|
561
|
+
|
|
562
|
+
if filters:
|
|
563
|
+
scope_exprs = self._resolve_filter_query_args(filters)
|
|
564
|
+
scoped = await self.model.find_one(
|
|
565
|
+
self.model.id == obj_id, *scope_exprs, fetch_links=False
|
|
566
|
+
)
|
|
567
|
+
if not scoped:
|
|
568
|
+
return None
|
|
569
|
+
|
|
528
570
|
return await self.model.find_one(
|
|
529
571
|
self.model.id == obj_id,
|
|
530
572
|
**self._get_query_kwargs(**kwargs),
|
|
@@ -82,9 +82,43 @@ class BaseService(Generic[ModelT]):
|
|
|
82
82
|
filters = filters or {}
|
|
83
83
|
return filters
|
|
84
84
|
|
|
85
|
+
def owner_filter(self, field: str, value: Any) -> Dict[str, Any]:
|
|
86
|
+
"""Filtro de scoping por un campo `Link` (ej. ownership por
|
|
87
|
+
`user`), correcto tanto en `list()` como en `retrieve`/`update`/
|
|
88
|
+
`delete` — sin que el service tenga que conocer por qué la clave
|
|
89
|
+
Mongo cambia entre esos dos caminos.
|
|
90
|
+
|
|
91
|
+
Por qué existen dos claves para "lo mismo": un campo `Link[User]`
|
|
92
|
+
se guarda como DBRef (`{"$ref": "users", "$id": ObjectId(...)}`).
|
|
93
|
+
- `retrieve`/`update`/`delete` van por `get_by_id`, que SIEMPRE
|
|
94
|
+
resuelve el scope con `fetch_links=False` (`find_one` plano sobre
|
|
95
|
+
el documento crudo) — ahí el DBRef está intacto, la clave correcta
|
|
96
|
+
es `"<field>.$id"`.
|
|
97
|
+
- `list()` (y cualquier action cuyo `get_kwargs_query()` pida
|
|
98
|
+
`fetch_links=True`) corre `Model.find(..., fetch_links=True)`,
|
|
99
|
+
que Beanie reescribe a una aggregation con `$lookup` — ahí el
|
|
100
|
+
`$lookup` REEMPLAZA el DBRef por el subdocumento resuelto, así
|
|
101
|
+
que la clave correcta pasa a ser `"<field>._id"`.
|
|
102
|
+
Usar la clave equivocada no tira error — devuelve 0 resultados en
|
|
103
|
+
silencio (bug real ya encontrado usando esto a mano en
|
|
104
|
+
`ToolService` de pulbot-backend, 2026-08-11). Este helper decide
|
|
105
|
+
por vos: usalo en vez de escribir `"<field>.$id"`/`"<field>._id"`
|
|
106
|
+
directo en tu `get_filters()`.
|
|
107
|
+
|
|
108
|
+
⚠️ Asume que tu action de lectura-por-id (`retrieve`/`update`/
|
|
109
|
+
`delete`) no fuerza `fetch_links=True` en el PROPIO chequeo de
|
|
110
|
+
scope — así es como quedó `get_by_id` del repo (siempre
|
|
111
|
+
`fetch_links=False` para el scope-check, independientemente del
|
|
112
|
+
`fetch_links` que pidas para el fetch final). Si tu repo está
|
|
113
|
+
parcheado para saltarse eso, este helper no aplica."""
|
|
114
|
+
suffix = "._id" if self.action == "list" else ".$id"
|
|
115
|
+
return {f"{field}{suffix}": value}
|
|
116
|
+
|
|
85
117
|
async def retrieve(self, id: str) -> ModelT:
|
|
86
118
|
kwargs = self.get_kwargs_query()
|
|
87
|
-
obj = await self.repository.get_by_id(
|
|
119
|
+
obj = await self.repository.get_by_id(
|
|
120
|
+
id, filters=self.get_filters(), **kwargs
|
|
121
|
+
)
|
|
88
122
|
if not obj:
|
|
89
123
|
raise NotFoundException(f"id={id} no encontrado")
|
|
90
124
|
return obj
|
|
@@ -241,7 +275,9 @@ class BaseService(Generic[ModelT]):
|
|
|
241
275
|
|
|
242
276
|
async def update(self, id: str, data: BaseModel) -> ModelT:
|
|
243
277
|
kwargs = self.get_kwargs_query()
|
|
244
|
-
obj = await self.repository.get_by_id(
|
|
278
|
+
obj = await self.repository.get_by_id(
|
|
279
|
+
id, filters=self.get_filters(), **kwargs
|
|
280
|
+
)
|
|
245
281
|
if not obj:
|
|
246
282
|
raise NotFoundException(f"id={id} no encontrado")
|
|
247
283
|
if isinstance(data, BaseModel):
|
|
@@ -250,7 +286,7 @@ class BaseService(Generic[ModelT]):
|
|
|
250
286
|
return updated
|
|
251
287
|
|
|
252
288
|
async def delete(self, id: str) -> str:
|
|
253
|
-
obj = await self.repository.get_by_id(id)
|
|
289
|
+
obj = await self.repository.get_by_id(id, filters=self.get_filters())
|
|
254
290
|
if not obj:
|
|
255
291
|
raise NotFoundException(f"id={id} no encontrado")
|
|
256
292
|
await self.repository.delete(obj)
|
{fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/repository/base.py
RENAMED
|
@@ -487,16 +487,37 @@ class BaseRepository(Generic[ModelT]):
|
|
|
487
487
|
|
|
488
488
|
return record
|
|
489
489
|
|
|
490
|
-
async def get(
|
|
491
|
-
|
|
490
|
+
async def get(
|
|
491
|
+
self,
|
|
492
|
+
record_id: Union[str, UUID],
|
|
493
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
494
|
+
) -> Optional[ModelT]:
|
|
495
|
+
"""Obtiene un registro por su ID.
|
|
496
|
+
|
|
497
|
+
`filters` (opcional) — scoping de seguridad (típicamente
|
|
498
|
+
`Service.get_filters()`). Sin `filters`, usa `session.get()` (fast
|
|
499
|
+
path por PK) — comportamiento idéntico a antes, 100% retrocompatible.
|
|
500
|
+
Con `filters`, cae a un `SELECT ... WHERE id = :id AND <filtros>` —
|
|
501
|
+
un id que existe pero no matchea el scope devuelve `None` (mismo
|
|
502
|
+
resultado que "no encontrado"), cerrando la clase de IDOR de "conozco
|
|
503
|
+
el id de otro tenant y lo pido directo"."""
|
|
492
504
|
if not self.model:
|
|
493
505
|
raise ValueError("El modelo no está definido en el repositorio")
|
|
494
|
-
|
|
506
|
+
if not filters:
|
|
507
|
+
return await self.session.get(self.model, record_id)
|
|
508
|
+
conditions = [self.model.id == record_id] + self._build_conditions(
|
|
509
|
+
filters=filters
|
|
510
|
+
)
|
|
511
|
+
return await self._get_one(conditions=conditions)
|
|
495
512
|
|
|
496
|
-
async def get_by_id(
|
|
513
|
+
async def get_by_id(
|
|
514
|
+
self,
|
|
515
|
+
record_id: Union[str, UUID],
|
|
516
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
517
|
+
) -> Optional[ModelT]:
|
|
497
518
|
"""Alias de `get(id)` — nombre unificado con el repo Beanie
|
|
498
519
|
(`get_by_id`) para que las lecturas por id sean portables entre ORMs."""
|
|
499
|
-
return await self.get(record_id)
|
|
520
|
+
return await self.get(record_id, filters=filters)
|
|
500
521
|
|
|
501
522
|
async def get_by_field(
|
|
502
523
|
self, field_name: str, value: Any
|
|
@@ -509,11 +530,15 @@ class BaseRepository(Generic[ModelT]):
|
|
|
509
530
|
self,
|
|
510
531
|
record_id: Union[str, UUID],
|
|
511
532
|
joins: Optional[List[str]] = None,
|
|
533
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
512
534
|
) -> Optional[ModelT]:
|
|
513
|
-
"""Obtiene un registro por ID y carga relaciones dinámicamente.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
535
|
+
"""Obtiene un registro por ID y carga relaciones dinámicamente.
|
|
536
|
+
|
|
537
|
+
`filters` (opcional) — mismo scoping de seguridad que `get()`."""
|
|
538
|
+
conditions = [self.model.id == record_id]
|
|
539
|
+
if filters:
|
|
540
|
+
conditions.extend(self._build_conditions(filters=filters))
|
|
541
|
+
return await self._get_one(conditions=conditions, joins=joins)
|
|
517
542
|
|
|
518
543
|
async def get_by_field_with_joins(
|
|
519
544
|
self,
|
{fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/service/base.py
RENAMED
|
@@ -122,9 +122,10 @@ class BaseService(Generic[ModelT]):
|
|
|
122
122
|
if joins is None:
|
|
123
123
|
joins = kwargs.get("joins")
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
filters = self.get_filters()
|
|
126
|
+
obj = await self.repository.get_with_joins(id, joins=joins, filters=filters)
|
|
126
127
|
if not obj:
|
|
127
|
-
obj = await self.repository.get(id)
|
|
128
|
+
obj = await self.repository.get(id, filters=filters)
|
|
128
129
|
if not obj:
|
|
129
130
|
raise NotFoundException(f"id={id} no encontrado")
|
|
130
131
|
return obj
|
|
@@ -227,6 +228,12 @@ class BaseService(Generic[ModelT]):
|
|
|
227
228
|
return created
|
|
228
229
|
|
|
229
230
|
async def update(self, id: str, data: BaseModel | Dict[str, Any]) -> ModelT:
|
|
231
|
+
# Scoping de seguridad ANTES de tocar nada — `repository.update`
|
|
232
|
+
# fetchea por PK cruda internamente (sin `get_filters()`), así que
|
|
233
|
+
# sin este check un id de otro tenant se podía editar directo.
|
|
234
|
+
scoped = await self.repository.get(id, filters=self.get_filters())
|
|
235
|
+
if not scoped:
|
|
236
|
+
raise NotFoundException(f"id={id} no encontrado")
|
|
230
237
|
update_data = (
|
|
231
238
|
data.model_dump(exclude_unset=True)
|
|
232
239
|
if isinstance(data, BaseModel)
|
|
@@ -277,7 +284,7 @@ class BaseService(Generic[ModelT]):
|
|
|
277
284
|
return True
|
|
278
285
|
|
|
279
286
|
async def delete(self, id: str) -> bool:
|
|
280
|
-
obj = await self.repository.get(id)
|
|
287
|
+
obj = await self.repository.get(id, filters=self.get_filters())
|
|
281
288
|
if not obj:
|
|
282
289
|
raise NotFoundException(message=f"id={id} no encontrado")
|
|
283
290
|
return await self.apply_delete(obj)
|
|
@@ -127,6 +127,7 @@ tests/test_generics_wiring.py
|
|
|
127
127
|
tests/test_jwt_config_robustness.py
|
|
128
128
|
tests/test_jwt_service.py
|
|
129
129
|
tests/test_openapi_simplify.py
|
|
130
|
+
tests/test_owner_filter_helper.py
|
|
130
131
|
tests/test_params_classification.py
|
|
131
132
|
tests/test_params_coercion_matrix.py
|
|
132
133
|
tests/test_params_coercion_unit.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-basekit"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.4"
|
|
8
8
|
description = "Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -242,3 +242,76 @@ async def test_scoped_filter_merges_with_user_filter(seeded):
|
|
|
242
242
|
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
243
243
|
items, total = await svc_t1.list(filters={"active": True}, count=50)
|
|
244
244
|
assert {i.name for i in items} == {"Alpha"} # t1 + active
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# get_filters scoping en retrieve/update/delete (IDOR — CLAUDE.md prometía
|
|
249
|
+
# esto ["Si necesitas un objeto por id con scope, usa `service.retrieve(id)`"]
|
|
250
|
+
# pero `retrieve`/`update`/`delete` llamaban `repository.get_by_id(id)`
|
|
251
|
+
# directo, bypasseando `get_filters()` igual que un `get_by_id` crudo. Un
|
|
252
|
+
# `ScopedItemService` (tenant t2) podía leer/editar/borrar un Item de t1
|
|
253
|
+
# solo con adivinar/enumerar su id. Fix: `retrieve`/`update`/`delete` ahora
|
|
254
|
+
# pasan `filters=self.get_filters()` a `repository.get_by_id`.
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_retrieve_respects_scope_own_tenant_ok(seeded):
|
|
259
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
260
|
+
got = await svc_t1.retrieve(str(seeded[0].id)) # Alpha, tenant t1
|
|
261
|
+
assert got.name == "Alpha"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.asyncio
|
|
265
|
+
async def test_retrieve_cross_tenant_id_is_not_found(seeded):
|
|
266
|
+
"""El id de Gamma (t2) existe de verdad — pedido desde un service
|
|
267
|
+
scopeado a t1 debe comportarse como si no existiera (404), no leakear
|
|
268
|
+
el registro de otro tenant."""
|
|
269
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
270
|
+
with pytest.raises(NotFoundException):
|
|
271
|
+
await svc_t1.retrieve(str(seeded[2].id)) # Gamma, tenant t2
|
|
272
|
+
|
|
273
|
+
# El mismo id SÍ resuelve para el tenant dueño.
|
|
274
|
+
svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
|
|
275
|
+
got = await svc_t2.retrieve(str(seeded[2].id))
|
|
276
|
+
assert got.name == "Gamma"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.asyncio
|
|
280
|
+
async def test_update_cross_tenant_id_is_not_found_and_not_modified(seeded):
|
|
281
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
282
|
+
with pytest.raises(NotFoundException):
|
|
283
|
+
await svc_t1.update(str(seeded[2].id), ItemUpdate(qty=999))
|
|
284
|
+
|
|
285
|
+
# No se modificó — lo confirma el dueño real (t2).
|
|
286
|
+
svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
|
|
287
|
+
unchanged = await svc_t2.retrieve(str(seeded[2].id))
|
|
288
|
+
assert unchanged.qty == 9
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@pytest.mark.asyncio
|
|
292
|
+
async def test_delete_cross_tenant_id_is_not_found_and_not_deleted(seeded):
|
|
293
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
294
|
+
with pytest.raises(NotFoundException):
|
|
295
|
+
await svc_t1.delete(str(seeded[2].id))
|
|
296
|
+
|
|
297
|
+
# Sigue existiendo — lo confirma el dueño real (t2).
|
|
298
|
+
svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
|
|
299
|
+
still_there = await svc_t2.retrieve(str(seeded[2].id))
|
|
300
|
+
assert still_there.name == "Gamma"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio
|
|
304
|
+
async def test_unscoped_service_retrieve_update_delete_unaffected(seeded):
|
|
305
|
+
"""Retrocompatibilidad: un service SIN override de `get_filters` (el
|
|
306
|
+
caso común, `get_filters` devuelve `{}`) sigue viendo/editando/borrando
|
|
307
|
+
cualquier id — cero cambio de comportamiento para el 99% de los
|
|
308
|
+
services existentes que no scopean por owner."""
|
|
309
|
+
svc = ItemService(repository=ItemRepo())
|
|
310
|
+
got = await svc.retrieve(str(seeded[2].id)) # Gamma, sin scoping
|
|
311
|
+
assert got.name == "Gamma"
|
|
312
|
+
|
|
313
|
+
updated = await svc.update(str(seeded[2].id), ItemUpdate(qty=42))
|
|
314
|
+
assert updated.qty == 42
|
|
315
|
+
|
|
316
|
+
res = await svc.delete(str(seeded[2].id))
|
|
317
|
+
assert res == "deleted"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`BaseService.owner_filter()` (Beanie) — helper que decide la clave Mongo
|
|
2
|
+
correcta para un filtro de ownership sobre un campo `Link`, según si la
|
|
3
|
+
query va a correr agregada (`list()`, con `fetch_links=True` → Beanie la
|
|
4
|
+
reescribe a `$lookup`, clave post-lookup `"<field>._id"`) o plana
|
|
5
|
+
(`retrieve`/`update`/`delete` vía `get_by_id`, que SIEMPRE escopea con
|
|
6
|
+
`fetch_links=False` → DBRef intacto, clave `"<field>.$id"`).
|
|
7
|
+
|
|
8
|
+
Caso real que motivó el helper (pulbot-backend, `ToolService`, 2026-08-11):
|
|
9
|
+
un service con `Link[User]` usaba la clave equivocada en `retrieve`/
|
|
10
|
+
`update`/`delete` (la misma que `list()`) — no tiraba error, devolvía 0
|
|
11
|
+
resultados en silencio → 404 en el delete de un recurso propio. Verificado
|
|
12
|
+
contra Mongo real (pulbot-backend, docker) tras el fix: 72/72 tests de
|
|
13
|
+
`app/tests/e2e/tool*` en verde.
|
|
14
|
+
|
|
15
|
+
Nota: este test es de LÓGICA PURA (sin DB) a propósito — `mongomock_motor`
|
|
16
|
+
(el mock que usa el resto de esta suite) no soporta queries dot-notation
|
|
17
|
+
sobre `DBRef` (`{"owner.$id": ...}` da 0 matches incluso contra el mismo
|
|
18
|
+
documento que lo tiene), así que un test contra mongomock de la mitad
|
|
19
|
+
`.$id` de este helper daría un falso negativo. La corrección de esa mitad
|
|
20
|
+
ya está verificada contra Mongo real en pulbot-backend; acá solo
|
|
21
|
+
caracterizamos que `owner_filter()` arma la clave correcta por `action`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from types import SimpleNamespace
|
|
25
|
+
|
|
26
|
+
from fastapi_basekit.aio.beanie.service.base import BaseService
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _FakeRepo:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _service(action: str) -> BaseService:
|
|
34
|
+
svc = BaseService(repository=_FakeRepo(), request=None)
|
|
35
|
+
svc.action = action
|
|
36
|
+
return svc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_owner_filter_uses_post_lookup_key_for_list():
|
|
40
|
+
svc = _service("list")
|
|
41
|
+
assert svc.owner_filter("user", "u1") == {"user._id": "u1"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_owner_filter_uses_dbref_key_for_retrieve():
|
|
45
|
+
svc = _service("retrieve")
|
|
46
|
+
assert svc.owner_filter("user", "u1") == {"user.$id": "u1"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_owner_filter_uses_dbref_key_for_update():
|
|
50
|
+
svc = _service("update")
|
|
51
|
+
assert svc.owner_filter("user", "u1") == {"user.$id": "u1"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_owner_filter_uses_dbref_key_for_delete():
|
|
55
|
+
svc = _service("delete")
|
|
56
|
+
assert svc.owner_filter("user", "u1") == {"user.$id": "u1"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_owner_filter_defaults_to_dbref_key_for_custom_actions():
|
|
60
|
+
"""Cualquier action que no sea "list" (ej. un endpoint custom que llama
|
|
61
|
+
`get_filters()` a mano) asume el camino no-agregado — es el lado
|
|
62
|
+
seguro: si algún día un action custom SÍ corre agregado, hay que optar
|
|
63
|
+
explícitamente pasando `"list"` o extendiendo el criterio, no al revés."""
|
|
64
|
+
svc = _service("approve")
|
|
65
|
+
assert svc.owner_filter("customer", "c1") == {"customer.$id": "c1"}
|
|
@@ -131,6 +131,79 @@ async def test_get_filters_scoping(seeded, session):
|
|
|
131
131
|
assert {u.name for u in items} == {"Ana"}
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# get_filters scoping en retrieve/update/delete (IDOR — mismo fix que en
|
|
136
|
+
# Beanie: `retrieve`/`update`/`delete` llamaban al repo por PK cruda,
|
|
137
|
+
# bypasseando `get_filters()`. Un service scopeado por `name="Ana"` podía
|
|
138
|
+
# leer/editar/borrar el registro de "Beto" solo con adivinar su id.)
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _ana_scoped_service(session):
|
|
142
|
+
class AnaScopedService(UserService):
|
|
143
|
+
def get_filters(self, filters=None):
|
|
144
|
+
filters = super().get_filters(filters)
|
|
145
|
+
filters["name"] = "Ana"
|
|
146
|
+
return filters
|
|
147
|
+
|
|
148
|
+
return AnaScopedService(repository=UserRepository(db=session), request=None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_retrieve_respects_scope_own_record_ok(seeded, session):
|
|
153
|
+
svc = _ana_scoped_service(session)
|
|
154
|
+
got = await svc.retrieve(str(seeded[0].id)) # Ana
|
|
155
|
+
assert got.name == "Ana"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_retrieve_out_of_scope_id_is_not_found(seeded, session, service):
|
|
160
|
+
svc = _ana_scoped_service(session)
|
|
161
|
+
with pytest.raises(NotFoundException):
|
|
162
|
+
await svc.retrieve(str(seeded[1].id)) # Beto, existe pero no matchea el scope
|
|
163
|
+
|
|
164
|
+
# El mismo id sí resuelve para un service sin ese scope.
|
|
165
|
+
got = await service.retrieve(str(seeded[1].id))
|
|
166
|
+
assert got.name == "Beto"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_update_out_of_scope_id_is_not_found_and_not_modified(
|
|
171
|
+
seeded, session, service
|
|
172
|
+
):
|
|
173
|
+
svc = _ana_scoped_service(session)
|
|
174
|
+
with pytest.raises(NotFoundException):
|
|
175
|
+
await svc.update(str(seeded[1].id), UserUpdateSchema(name="Hijacked"))
|
|
176
|
+
|
|
177
|
+
unchanged = await service.retrieve(str(seeded[1].id))
|
|
178
|
+
assert unchanged.name == "Beto"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_delete_out_of_scope_id_is_not_found_and_not_deleted(
|
|
183
|
+
seeded, session, service
|
|
184
|
+
):
|
|
185
|
+
svc = _ana_scoped_service(session)
|
|
186
|
+
with pytest.raises(NotFoundException):
|
|
187
|
+
await svc.delete(str(seeded[1].id))
|
|
188
|
+
|
|
189
|
+
still_there = await service.retrieve(str(seeded[1].id))
|
|
190
|
+
assert still_there.name == "Beto"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_unscoped_service_retrieve_update_delete_unaffected(seeded, service):
|
|
195
|
+
"""Retrocompatibilidad: sin override de `get_filters` (el caso común),
|
|
196
|
+
cero cambio de comportamiento."""
|
|
197
|
+
got = await service.retrieve(str(seeded[1].id))
|
|
198
|
+
assert got.name == "Beto"
|
|
199
|
+
|
|
200
|
+
updated = await service.update(str(seeded[1].id), UserUpdateSchema(name="Beto2"))
|
|
201
|
+
assert updated.name == "Beto2"
|
|
202
|
+
|
|
203
|
+
ok = await service.delete(str(seeded[1].id))
|
|
204
|
+
assert ok is True
|
|
205
|
+
|
|
206
|
+
|
|
134
207
|
# ---------------------------------------------------------------------------
|
|
135
208
|
# Fix issue #7: aislamiento de mutable-defaults por instancia
|
|
136
209
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|