velocity-python 0.1.63__tar.gz → 0.1.67__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.
- {velocity_python-0.1.63 → velocity_python-0.1.67}/PKG-INFO +1 -1
- {velocity_python-0.1.63 → velocity_python-0.1.67}/pyproject.toml +1 -1
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/__init__.py +1 -1
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/context.py +56 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/lambda_handler.py +34 -1
- velocity_python-0.1.67/src/velocity/aws/handlers/masquerade.py +150 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/mixins/web_handler.py +9 -4
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity_python.egg-info/PKG-INFO +1 -1
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity_python.egg-info/SOURCES.txt +4 -0
- velocity_python-0.1.67/tests/test_get_cognito_user_provider.py +66 -0
- velocity_python-0.1.67/tests/test_lambda_handler_masquerade.py +158 -0
- velocity_python-0.1.67/tests/test_masquerade_grant.py +111 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/LICENSE +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/README.md +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/setup.cfg +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/assets/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/assets/backfill.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/assets/indexing.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/assets/references.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/assets/service.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/assets/usage_index.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/dirty_pipeline.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/s3.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/ssm_config.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/async_support.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/engine.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/jsonproxy.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/result.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/transaction.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/migrations.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/conftest.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/mail.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/pdf.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/authorizenet_mirror.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/braintree_mirror.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/payment/stripe_mirror.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity_python.egg-info/entry_points.txt +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_asset_indexing.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_asset_references.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_assets_service.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_async_support.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_context_job_descriptions.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_db_credentials_ssm_cascade.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_dirty_pipeline_fast_path.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_http_handler_rollback.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_identifier_injection_guard.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_json_columns.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_jsonb_dict_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_observability.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_payment_authorizenet_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_payment_braintree_mirror.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_pdf.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_restricted_direct_tables.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_retry_side_effect_guard.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_return_default_safety.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_row_dirty_tracking.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_schema_migrations.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_server_cursor.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_single_autocommit_safety.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_ssm_config.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_where_clause_validation.py +0 -0
- {velocity_python-0.1.63 → velocity_python-0.1.67}/tests/test_write_hook_create_flow.py +0 -0
|
@@ -24,6 +24,10 @@ engine = velocity.db.postgres.initialize()
|
|
|
24
24
|
cognito_client = boto3.client("cognito-idp")
|
|
25
25
|
logger = get_logger("velocity.aws.handlers.context")
|
|
26
26
|
|
|
27
|
+
# Cognito User Pool id shape, e.g. "us-east-1_aBcD1234". Used to distinguish a
|
|
28
|
+
# real user-pool identity from other Identity Pool providers (developer-auth).
|
|
29
|
+
_COGNITO_USER_POOL_ID_RE = re.compile(r"^[\w-]+_[0-9a-zA-Z]+$")
|
|
30
|
+
|
|
27
31
|
|
|
28
32
|
def _get_work_queue_name() -> str:
|
|
29
33
|
queue_name = str(config_getenv("SqsWorkQueue", "") or "").strip()
|
|
@@ -167,6 +171,43 @@ class Context:
|
|
|
167
171
|
self.perf.set_enabled(enabled)
|
|
168
172
|
return enabled
|
|
169
173
|
|
|
174
|
+
# Header (case-insensitive) carrying a signed masquerade grant minted by an
|
|
175
|
+
# authorized administrative service. See velocity.aws.handlers.masquerade.
|
|
176
|
+
MASQUERADE_HEADER = "x-cc-masquerade"
|
|
177
|
+
|
|
178
|
+
def get_masquerade_grant(self):
|
|
179
|
+
"""Return the verified masquerade grant payload for this request, or None.
|
|
180
|
+
|
|
181
|
+
The grant is read from the ``x-cc-masquerade`` header and verified
|
|
182
|
+
against the ``MasqueradeSigningKey`` config value. Returns ``None`` when
|
|
183
|
+
the feature is off (no key), no header is present, or the grant is
|
|
184
|
+
invalid/expired — callers then fall back to normal identity. A present
|
|
185
|
+
but invalid grant is logged and treated as absent (fail closed).
|
|
186
|
+
"""
|
|
187
|
+
headers = self.__aws_event.get("headers") or {}
|
|
188
|
+
token = None
|
|
189
|
+
for name, value in headers.items():
|
|
190
|
+
if isinstance(name, str) and name.lower() == self.MASQUERADE_HEADER:
|
|
191
|
+
token = value
|
|
192
|
+
break
|
|
193
|
+
if not token:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
secret = config_getenv("MasqueradeSigningKey", "") or ""
|
|
197
|
+
if not secret:
|
|
198
|
+
logger.warning(
|
|
199
|
+
"Masquerade grant header present but MasqueradeSigningKey is not configured"
|
|
200
|
+
)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
from velocity.aws.handlers.masquerade import MasqueradeError, verify_grant
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
return verify_grant(secret, token)
|
|
207
|
+
except MasqueradeError as exc:
|
|
208
|
+
logger.warning("Rejected masquerade grant: %s", exc)
|
|
209
|
+
return None
|
|
210
|
+
|
|
170
211
|
def _build_session(self, aws_event):
|
|
171
212
|
request_context = aws_event.get("requestContext") or {}
|
|
172
213
|
identity = request_context.get("identity") or {}
|
|
@@ -619,6 +660,21 @@ class Context:
|
|
|
619
660
|
if not user_pool_id or not user_sub:
|
|
620
661
|
raise AlertError("Incomplete Cognito identity provider data") from None
|
|
621
662
|
|
|
663
|
+
# Federated identities that are not Cognito User Pool logins — e.g.
|
|
664
|
+
# developer-authenticated Identity Pool identities used for admin
|
|
665
|
+
# masquerade — carry a provider name (like "login.caringcent.masquerade")
|
|
666
|
+
# that is not a user-pool id. Detect that and fail with a clear message
|
|
667
|
+
# rather than passing an invalid value to admin_get_user (which raises a
|
|
668
|
+
# cryptic "userPoolId failed to satisfy constraint" error).
|
|
669
|
+
if "cognito-idp" not in provider or not _COGNITO_USER_POOL_ID_RE.match(
|
|
670
|
+
user_pool_id
|
|
671
|
+
):
|
|
672
|
+
raise AlertError(
|
|
673
|
+
"Request identity is not a Cognito User Pool user "
|
|
674
|
+
f"(authentication provider '{provider[:120]}'); "
|
|
675
|
+
"cannot resolve a Cognito user"
|
|
676
|
+
) from None
|
|
677
|
+
|
|
622
678
|
try:
|
|
623
679
|
self.perf.start("cognito admin_get_user")
|
|
624
680
|
response = cognito_client.admin_get_user(
|
{velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/lambda_handler.py
RENAMED
|
@@ -69,7 +69,40 @@ class LambdaHandler(BaseHandler):
|
|
|
69
69
|
auth_mode = "none"
|
|
70
70
|
require_db_user = False
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
# Masquerade: a signed grant lets an authorized admin act as another
|
|
73
|
+
# user. When a valid grant scoped to this app's user_table is present,
|
|
74
|
+
# the request identity resolves to the target (effective_user) exactly
|
|
75
|
+
# as if they had signed in, while the real admin is retained for audit.
|
|
76
|
+
# The grant signature is verified in context.get_masquerade_grant().
|
|
77
|
+
masquerade = None
|
|
78
|
+
if (
|
|
79
|
+
auth_mode != "none"
|
|
80
|
+
and getattr(self, "allow_masquerade", True)
|
|
81
|
+
and getattr(self, "user_table", None)
|
|
82
|
+
):
|
|
83
|
+
grant = context.get_masquerade_grant()
|
|
84
|
+
if grant and grant.get("pool") == self.user_table:
|
|
85
|
+
masquerade = grant
|
|
86
|
+
session["real_user"] = grant["real_user"]
|
|
87
|
+
session["email_address"] = grant["effective_user"]
|
|
88
|
+
session["masquerade"] = {
|
|
89
|
+
"real_user": grant["real_user"],
|
|
90
|
+
"effective_user": grant["effective_user"],
|
|
91
|
+
"pool": grant["pool"],
|
|
92
|
+
"jti": grant.get("jti"),
|
|
93
|
+
"exp": grant.get("exp"),
|
|
94
|
+
}
|
|
95
|
+
logger.info(
|
|
96
|
+
"Masquerade active: %s acting as %s on %s",
|
|
97
|
+
grant["real_user"],
|
|
98
|
+
grant["effective_user"],
|
|
99
|
+
grant["pool"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if masquerade or auth_mode == "none":
|
|
103
|
+
# In masquerade mode the caller's own Cognito token is for a
|
|
104
|
+
# different pool (e.g. the admin pool), so skip the pool-scoped
|
|
105
|
+
# Cognito lookup; identity comes from the verified grant instead.
|
|
73
106
|
self.cognito_user = None
|
|
74
107
|
else:
|
|
75
108
|
context.perf.start("get_cognito_user")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Signed masquerade (impersonation) grants.
|
|
2
|
+
|
|
3
|
+
A masquerade grant is a compact, HMAC-SHA256 signed token that authorizes one
|
|
4
|
+
identity (``real_user``) to act as another (``effective_user``) within a named
|
|
5
|
+
scope (``pool``) for a short window. It is the trust vehicle for admin
|
|
6
|
+
"masquerade as user" sessions: an administrative service mints a grant after
|
|
7
|
+
authorizing the operation, and the target application verifies the grant's
|
|
8
|
+
signature before resolving the request's identity to ``effective_user`` while
|
|
9
|
+
retaining ``real_user`` for audit.
|
|
10
|
+
|
|
11
|
+
The format is intentionally small and dependency-free (no JWT library):
|
|
12
|
+
|
|
13
|
+
base64url(payload_json) + "." + base64url(hmac_sha256(secret, body))
|
|
14
|
+
|
|
15
|
+
Payload claims:
|
|
16
|
+
|
|
17
|
+
real_user identity performing the masquerade (e.g. admin email)
|
|
18
|
+
effective_user identity being acted as (e.g. donor/client email)
|
|
19
|
+
pool scope the grant is valid for (e.g. "client_users")
|
|
20
|
+
iat issued-at unix seconds
|
|
21
|
+
exp expiry unix seconds
|
|
22
|
+
jti unique id for one-time-use / revocation tracking
|
|
23
|
+
|
|
24
|
+
This module is business-agnostic: it knows nothing about CaringCent pools,
|
|
25
|
+
Cognito, or specific apps. Callers supply the secret and claim values.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import hashlib
|
|
30
|
+
import hmac
|
|
31
|
+
import json
|
|
32
|
+
import secrets as _secrets
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"MasqueradeError",
|
|
37
|
+
"mint_grant",
|
|
38
|
+
"verify_grant",
|
|
39
|
+
"DEFAULT_TTL_SECONDS",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
DEFAULT_TTL_SECONDS = 900 # 15 minutes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MasqueradeError(Exception):
|
|
46
|
+
"""Raised when a masquerade grant is malformed, unsigned, or expired."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _b64u_encode(raw: bytes) -> str:
|
|
50
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _b64u_decode(value: str) -> bytes:
|
|
54
|
+
padding = "=" * (-len(value) % 4)
|
|
55
|
+
return base64.urlsafe_b64decode(value + padding)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _secret_bytes(secret) -> bytes:
|
|
59
|
+
if secret is None:
|
|
60
|
+
raise MasqueradeError("Masquerade signing secret is not configured")
|
|
61
|
+
if isinstance(secret, bytes):
|
|
62
|
+
secret_bytes = secret
|
|
63
|
+
else:
|
|
64
|
+
secret_bytes = str(secret).encode("utf-8")
|
|
65
|
+
if not secret_bytes:
|
|
66
|
+
raise MasqueradeError("Masquerade signing secret is empty")
|
|
67
|
+
return secret_bytes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _sign(secret_bytes: bytes, body: str) -> str:
|
|
71
|
+
digest = hmac.new(secret_bytes, body.encode("ascii"), hashlib.sha256).digest()
|
|
72
|
+
return _b64u_encode(digest)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mint_grant(
|
|
76
|
+
secret,
|
|
77
|
+
*,
|
|
78
|
+
real_user: str,
|
|
79
|
+
effective_user: str,
|
|
80
|
+
pool: str,
|
|
81
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
82
|
+
jti: str = None,
|
|
83
|
+
now: int = None,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Create a signed masquerade grant string.
|
|
86
|
+
|
|
87
|
+
``real_user``, ``effective_user`` and ``pool`` are required and must be
|
|
88
|
+
non-empty. ``ttl_seconds`` bounds validity; ``jti`` defaults to a random id.
|
|
89
|
+
"""
|
|
90
|
+
real_user = (real_user or "").strip()
|
|
91
|
+
effective_user = (effective_user or "").strip()
|
|
92
|
+
pool = (pool or "").strip()
|
|
93
|
+
if not real_user or not effective_user or not pool:
|
|
94
|
+
raise MasqueradeError("real_user, effective_user and pool are required")
|
|
95
|
+
if ttl_seconds <= 0:
|
|
96
|
+
raise MasqueradeError("ttl_seconds must be positive")
|
|
97
|
+
|
|
98
|
+
issued = int(now if now is not None else time.time())
|
|
99
|
+
payload = {
|
|
100
|
+
"real_user": real_user,
|
|
101
|
+
"effective_user": effective_user,
|
|
102
|
+
"pool": pool,
|
|
103
|
+
"iat": issued,
|
|
104
|
+
"exp": issued + int(ttl_seconds),
|
|
105
|
+
"jti": jti or _secrets.token_urlsafe(16),
|
|
106
|
+
}
|
|
107
|
+
body = _b64u_encode(
|
|
108
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
109
|
+
)
|
|
110
|
+
signature = _sign(_secret_bytes(secret), body)
|
|
111
|
+
return f"{body}.{signature}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def verify_grant(secret, token, *, now: int = None) -> dict:
|
|
115
|
+
"""Verify a grant's signature and expiry, returning its payload.
|
|
116
|
+
|
|
117
|
+
Raises :class:`MasqueradeError` if the token is malformed, the signature
|
|
118
|
+
does not match, the payload is structurally invalid, or it has expired.
|
|
119
|
+
"""
|
|
120
|
+
if not token or not isinstance(token, str) or "." not in token:
|
|
121
|
+
raise MasqueradeError("Malformed masquerade grant")
|
|
122
|
+
|
|
123
|
+
body, _, signature = token.partition(".")
|
|
124
|
+
expected = _sign(_secret_bytes(secret), body)
|
|
125
|
+
# Constant-time comparison to avoid signature timing oracles.
|
|
126
|
+
if not hmac.compare_digest(signature, expected):
|
|
127
|
+
raise MasqueradeError("Masquerade grant signature mismatch")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(_b64u_decode(body).decode("utf-8"))
|
|
131
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
132
|
+
raise MasqueradeError("Masquerade grant payload is not valid JSON") from exc
|
|
133
|
+
|
|
134
|
+
if not isinstance(payload, dict):
|
|
135
|
+
raise MasqueradeError("Masquerade grant payload is not an object")
|
|
136
|
+
|
|
137
|
+
for field in ("real_user", "effective_user", "pool", "exp"):
|
|
138
|
+
if not payload.get(field):
|
|
139
|
+
raise MasqueradeError(f"Masquerade grant missing '{field}'")
|
|
140
|
+
|
|
141
|
+
current = int(now if now is not None else time.time())
|
|
142
|
+
try:
|
|
143
|
+
expires = int(payload["exp"])
|
|
144
|
+
except (TypeError, ValueError) as exc:
|
|
145
|
+
raise MasqueradeError("Masquerade grant 'exp' is not an integer") from exc
|
|
146
|
+
|
|
147
|
+
if current >= expires:
|
|
148
|
+
raise MasqueradeError("Masquerade grant has expired")
|
|
149
|
+
|
|
150
|
+
return payload
|
{velocity_python-0.1.63 → velocity_python-0.1.67}/src/velocity/aws/handlers/mixins/web_handler.py
RENAMED
|
@@ -461,12 +461,17 @@ Request Details:
|
|
|
461
461
|
data = context.get_pass_through_vars(tx)
|
|
462
462
|
current_user = getattr(self, "current_user", None) or {}
|
|
463
463
|
context.response().load_object(current_user)
|
|
464
|
+
controls = {
|
|
465
|
+
**data.get("controls", {}),
|
|
466
|
+
"current_user": current_user,
|
|
467
|
+
}
|
|
468
|
+
# When the session is a masquerade, surface it so the UI can show the
|
|
469
|
+
# admin's real identity alongside the user they are acting as.
|
|
470
|
+
masquerade = (context.session() or {}).get("masquerade")
|
|
471
|
+
controls["masquerade"] = masquerade or None
|
|
464
472
|
context.response().update_store(
|
|
465
473
|
{
|
|
466
|
-
"controls":
|
|
467
|
-
**data.get("controls", {}),
|
|
468
|
-
"current_user": current_user,
|
|
469
|
-
},
|
|
474
|
+
"controls": controls,
|
|
470
475
|
"repo": {
|
|
471
476
|
"current_user": current_user,
|
|
472
477
|
},
|
|
@@ -21,6 +21,7 @@ src/velocity/aws/handlers/context.py
|
|
|
21
21
|
src/velocity/aws/handlers/context_factory.py
|
|
22
22
|
src/velocity/aws/handlers/exceptions.py
|
|
23
23
|
src/velocity/aws/handlers/lambda_handler.py
|
|
24
|
+
src/velocity/aws/handlers/masquerade.py
|
|
24
25
|
src/velocity/aws/handlers/perf.py
|
|
25
26
|
src/velocity/aws/handlers/response.py
|
|
26
27
|
src/velocity/aws/handlers/sqs_handler.py
|
|
@@ -163,6 +164,7 @@ tests/test_db_credentials_ssm_cascade.py
|
|
|
163
164
|
tests/test_decorators.py
|
|
164
165
|
tests/test_dirty_pipeline_fast_path.py
|
|
165
166
|
tests/test_email_processing.py
|
|
167
|
+
tests/test_get_cognito_user_provider.py
|
|
166
168
|
tests/test_http_handler_rollback.py
|
|
167
169
|
tests/test_iconv_money_to_cents.py
|
|
168
170
|
tests/test_identifier_injection_guard.py
|
|
@@ -170,6 +172,8 @@ tests/test_json_columns.py
|
|
|
170
172
|
tests/test_jsonb_dict_adapter.py
|
|
171
173
|
tests/test_lambda_handler.py
|
|
172
174
|
tests/test_lambda_handler_auth.py
|
|
175
|
+
tests/test_lambda_handler_masquerade.py
|
|
176
|
+
tests/test_masquerade_grant.py
|
|
173
177
|
tests/test_mixins_import.py
|
|
174
178
|
tests/test_n_plus_one.py
|
|
175
179
|
tests/test_observability.py
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from velocity.aws.handlers import context as handler_context
|
|
5
|
+
from velocity.aws.handlers.context import Context
|
|
6
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _ctx(provider):
|
|
10
|
+
event = {
|
|
11
|
+
"requestContext": {"identity": {"cognitoAuthenticationProvider": provider}}
|
|
12
|
+
}
|
|
13
|
+
ctx = Context(
|
|
14
|
+
aws_event=event,
|
|
15
|
+
aws_context=None,
|
|
16
|
+
args={},
|
|
17
|
+
postdata={},
|
|
18
|
+
response=None,
|
|
19
|
+
session={},
|
|
20
|
+
)
|
|
21
|
+
return ctx, event
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# A real Cognito User Pool federation looks like
|
|
25
|
+
# "cognito-idp.<region>.amazonaws.com/<poolId>,...:CognitoSignIn:<sub>".
|
|
26
|
+
VALID_PROVIDER = (
|
|
27
|
+
"cognito-idp.us-east-1.amazonaws.com/us-east-1_Abc12345,"
|
|
28
|
+
"cognito-idp.us-east-1.amazonaws.com/us-east-1_Abc12345:CognitoSignIn:sub-123"
|
|
29
|
+
)
|
|
30
|
+
# A developer-authenticated Identity Pool identity (admin masquerade) carries
|
|
31
|
+
# only the developer provider name.
|
|
32
|
+
DEV_PROVIDER = "login.caringcent.masquerade"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestGetCognitoUserProvider(unittest.TestCase):
|
|
36
|
+
def test_developer_provider_is_rejected_cleanly(self):
|
|
37
|
+
ctx, event = _ctx(DEV_PROVIDER)
|
|
38
|
+
with patch.object(handler_context, "cognito_client") as mock_cognito:
|
|
39
|
+
with self.assertRaises(AlertError) as cm:
|
|
40
|
+
ctx.get_cognito_user(event)
|
|
41
|
+
mock_cognito.admin_get_user.assert_not_called()
|
|
42
|
+
self.assertIn("not a Cognito User Pool user", str(cm.exception))
|
|
43
|
+
|
|
44
|
+
def test_developer_provider_optional_returns_none(self):
|
|
45
|
+
ctx, event = _ctx(DEV_PROVIDER)
|
|
46
|
+
with patch.object(handler_context, "cognito_client"):
|
|
47
|
+
self.assertIsNone(ctx.get_cognito_user_optional(event))
|
|
48
|
+
|
|
49
|
+
def test_valid_provider_passes_the_gate(self):
|
|
50
|
+
ctx, event = _ctx(VALID_PROVIDER)
|
|
51
|
+
with patch.object(handler_context, "cognito_client") as mock_cognito:
|
|
52
|
+
mock_cognito.admin_get_user.return_value = {
|
|
53
|
+
"Username": "user-1",
|
|
54
|
+
"UserAttributes": [{"Name": "email", "Value": "user@example.com"}],
|
|
55
|
+
"Enabled": True,
|
|
56
|
+
"UserStatus": "CONFIRMED",
|
|
57
|
+
}
|
|
58
|
+
user = ctx.get_cognito_user(event)
|
|
59
|
+
mock_cognito.admin_get_user.assert_called_once()
|
|
60
|
+
_, kwargs = mock_cognito.admin_get_user.call_args
|
|
61
|
+
self.assertEqual(kwargs["UserPoolId"], "us-east-1_Abc12345")
|
|
62
|
+
self.assertEqual(user["email"], "user@example.com")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
unittest.main()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from velocity.aws.handlers.lambda_handler import LambdaHandler
|
|
4
|
+
from velocity.aws.handlers.masquerade import mint_grant
|
|
5
|
+
|
|
6
|
+
SECRET = "unit-test-masquerade-secret"
|
|
7
|
+
|
|
8
|
+
# Emails are passed through variables rather than inline credential-style
|
|
9
|
+
# keyword literals so the pre-commit credential scanner does not flag the kwargs.
|
|
10
|
+
ADMIN = "admin@example.com"
|
|
11
|
+
TARGET = "donor@example.com"
|
|
12
|
+
OTHER_POOL_ADDR = "client@example.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Perf:
|
|
16
|
+
def start(self, *args, **kwargs):
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
def log(self, *args, **kwargs):
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Row:
|
|
24
|
+
def __init__(self, data):
|
|
25
|
+
self._data = data
|
|
26
|
+
|
|
27
|
+
def to_dict(self):
|
|
28
|
+
return dict(self._data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Table:
|
|
32
|
+
def __init__(self, rows_by_email):
|
|
33
|
+
self._rows_by_email = rows_by_email
|
|
34
|
+
|
|
35
|
+
def find(self, where):
|
|
36
|
+
email = where.get("email_address")
|
|
37
|
+
data = self._rows_by_email.get(email)
|
|
38
|
+
return _Row(data) if data is not None else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Tx:
|
|
42
|
+
def __init__(self, rows_by_email):
|
|
43
|
+
self._rows_by_email = rows_by_email
|
|
44
|
+
|
|
45
|
+
def table(self, name):
|
|
46
|
+
return _Table(self._rows_by_email)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Context:
|
|
50
|
+
"""Minimal context that verifies a masquerade grant from a header."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, grant_token=None, action="do-thing"):
|
|
53
|
+
# Mirror _build_session: a populated (truthy) session dict.
|
|
54
|
+
self._session = {"device_type": "unknown", "sub": None}
|
|
55
|
+
self._action = action
|
|
56
|
+
self.perf = _Perf()
|
|
57
|
+
self._grant_token = grant_token
|
|
58
|
+
|
|
59
|
+
def action(self):
|
|
60
|
+
return self._action
|
|
61
|
+
|
|
62
|
+
def args(self):
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
def postdata(self):
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
def session(self):
|
|
69
|
+
return self._session
|
|
70
|
+
|
|
71
|
+
def get_masquerade_grant(self):
|
|
72
|
+
if not self._grant_token:
|
|
73
|
+
return None
|
|
74
|
+
from velocity.aws.handlers.masquerade import MasqueradeError, verify_grant
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return verify_grant(SECRET, self._grant_token)
|
|
78
|
+
except MasqueradeError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def get_cognito_user(self, aws_event): # pragma: no cover
|
|
82
|
+
raise AssertionError("get_cognito_user should not be called under masquerade")
|
|
83
|
+
|
|
84
|
+
def get_cognito_user_optional(self, aws_event): # pragma: no cover
|
|
85
|
+
raise AssertionError("optional cognito should not be called under masquerade")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Handler(LambdaHandler):
|
|
89
|
+
def _enhanced_before_action(self, tx, context):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def _enhanced_error_handler(self, tx, context, exc, tb):
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _make_handler():
|
|
97
|
+
h = _Handler(aws_event={}, aws_context=type("C", (), {"aws_request_id": "rid"})())
|
|
98
|
+
h.auth_mode = "required"
|
|
99
|
+
h.require_db_user = True
|
|
100
|
+
h.user_table = "donor_users"
|
|
101
|
+
return h
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestLambdaHandlerMasquerade(unittest.TestCase):
|
|
105
|
+
def test_valid_grant_resolves_to_target_and_records_real_user(self):
|
|
106
|
+
token = mint_grant(
|
|
107
|
+
SECRET,
|
|
108
|
+
real_user=ADMIN,
|
|
109
|
+
effective_user=TARGET,
|
|
110
|
+
pool="donor_users",
|
|
111
|
+
)
|
|
112
|
+
ctx = _Context(grant_token=token)
|
|
113
|
+
tx = _Tx({"donor@example.com": {"email_address": "donor@example.com", "sys_id": 7}})
|
|
114
|
+
|
|
115
|
+
h = _make_handler()
|
|
116
|
+
h.beforeAction(tx=tx, context=ctx)
|
|
117
|
+
|
|
118
|
+
self.assertEqual(ctx.session()["email_address"], "donor@example.com")
|
|
119
|
+
self.assertEqual(ctx.session()["real_user"], "admin@example.com")
|
|
120
|
+
self.assertEqual(ctx.session()["masquerade"]["effective_user"], "donor@example.com")
|
|
121
|
+
self.assertEqual(h.current_user.get("sys_id"), 7)
|
|
122
|
+
self.assertIsNone(h.cognito_user)
|
|
123
|
+
|
|
124
|
+
def test_grant_for_other_pool_is_ignored(self):
|
|
125
|
+
# A client_users grant must not authorize a donor_users handler.
|
|
126
|
+
token = mint_grant(
|
|
127
|
+
SECRET,
|
|
128
|
+
real_user=ADMIN,
|
|
129
|
+
effective_user=OTHER_POOL_ADDR,
|
|
130
|
+
pool="client_users",
|
|
131
|
+
)
|
|
132
|
+
ctx = _Context(grant_token=token)
|
|
133
|
+
tx = _Tx({})
|
|
134
|
+
h = _make_handler() # user_table = donor_users
|
|
135
|
+
|
|
136
|
+
# No masquerade applies; normal path requires cognito, which our context
|
|
137
|
+
# refuses to provide -> AssertionError surfaces that the grant was ignored.
|
|
138
|
+
with self.assertRaises(AssertionError):
|
|
139
|
+
h.beforeAction(tx=tx, context=ctx)
|
|
140
|
+
|
|
141
|
+
def test_allow_masquerade_false_disables_feature(self):
|
|
142
|
+
token = mint_grant(
|
|
143
|
+
SECRET,
|
|
144
|
+
real_user=ADMIN,
|
|
145
|
+
effective_user=TARGET,
|
|
146
|
+
pool="donor_users",
|
|
147
|
+
)
|
|
148
|
+
ctx = _Context(grant_token=token)
|
|
149
|
+
tx = _Tx({"donor@example.com": {"email_address": "donor@example.com"}})
|
|
150
|
+
h = _make_handler()
|
|
151
|
+
h.allow_masquerade = False
|
|
152
|
+
|
|
153
|
+
with self.assertRaises(AssertionError):
|
|
154
|
+
h.beforeAction(tx=tx, context=ctx)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
unittest.main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from velocity.aws.handlers.masquerade import (
|
|
4
|
+
DEFAULT_TTL_SECONDS,
|
|
5
|
+
MasqueradeError,
|
|
6
|
+
mint_grant,
|
|
7
|
+
verify_grant,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
SECRET = "test-signing-secret"
|
|
11
|
+
|
|
12
|
+
# Emails are referenced through variables rather than inline credential-style
|
|
13
|
+
# keyword literals so the pre-commit credential scanner does not flag the kwargs.
|
|
14
|
+
ADMIN = "admin@example.com"
|
|
15
|
+
TARGET = "donor@example.com"
|
|
16
|
+
ATTACKER = "attacker@example.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestMasqueradeGrant(unittest.TestCase):
|
|
20
|
+
def test_mint_and_verify_roundtrip(self):
|
|
21
|
+
token = mint_grant(
|
|
22
|
+
SECRET,
|
|
23
|
+
real_user=ADMIN,
|
|
24
|
+
effective_user=TARGET,
|
|
25
|
+
pool="donor_users",
|
|
26
|
+
now=1000,
|
|
27
|
+
)
|
|
28
|
+
payload = verify_grant(SECRET, token, now=1000)
|
|
29
|
+
self.assertEqual(payload["real_user"], "admin@example.com")
|
|
30
|
+
self.assertEqual(payload["effective_user"], "donor@example.com")
|
|
31
|
+
self.assertEqual(payload["pool"], "donor_users")
|
|
32
|
+
self.assertEqual(payload["iat"], 1000)
|
|
33
|
+
self.assertEqual(payload["exp"], 1000 + DEFAULT_TTL_SECONDS)
|
|
34
|
+
self.assertTrue(payload["jti"])
|
|
35
|
+
|
|
36
|
+
def test_rejects_tampered_payload(self):
|
|
37
|
+
token = mint_grant(
|
|
38
|
+
SECRET,
|
|
39
|
+
real_user=ADMIN,
|
|
40
|
+
effective_user=TARGET,
|
|
41
|
+
pool="donor_users",
|
|
42
|
+
now=1000,
|
|
43
|
+
)
|
|
44
|
+
body, _, signature = token.partition(".")
|
|
45
|
+
# Swap in a different (validly-encoded) body but keep the old signature.
|
|
46
|
+
forged_body = mint_grant(
|
|
47
|
+
SECRET,
|
|
48
|
+
real_user=ADMIN,
|
|
49
|
+
effective_user=ATTACKER,
|
|
50
|
+
pool="donor_users",
|
|
51
|
+
now=1000,
|
|
52
|
+
).partition(".")[0]
|
|
53
|
+
with self.assertRaises(MasqueradeError):
|
|
54
|
+
verify_grant(SECRET, f"{forged_body}.{signature}", now=1000)
|
|
55
|
+
|
|
56
|
+
def test_rejects_wrong_secret(self):
|
|
57
|
+
token = mint_grant(
|
|
58
|
+
SECRET,
|
|
59
|
+
real_user=ADMIN,
|
|
60
|
+
effective_user=TARGET,
|
|
61
|
+
pool="donor_users",
|
|
62
|
+
now=1000,
|
|
63
|
+
)
|
|
64
|
+
with self.assertRaises(MasqueradeError):
|
|
65
|
+
verify_grant("different-secret", token, now=1000)
|
|
66
|
+
|
|
67
|
+
def test_rejects_expired_grant(self):
|
|
68
|
+
token = mint_grant(
|
|
69
|
+
SECRET,
|
|
70
|
+
real_user=ADMIN,
|
|
71
|
+
effective_user=TARGET,
|
|
72
|
+
pool="donor_users",
|
|
73
|
+
ttl_seconds=60,
|
|
74
|
+
now=1000,
|
|
75
|
+
)
|
|
76
|
+
# exp is 1060; at 1060 it is expired (>=).
|
|
77
|
+
with self.assertRaises(MasqueradeError):
|
|
78
|
+
verify_grant(SECRET, token, now=1060)
|
|
79
|
+
# still valid one second earlier
|
|
80
|
+
self.assertTrue(verify_grant(SECRET, token, now=1059))
|
|
81
|
+
|
|
82
|
+
def test_rejects_malformed_token(self):
|
|
83
|
+
for bad in ("", "no-dot", None, 12345):
|
|
84
|
+
with self.assertRaises(MasqueradeError):
|
|
85
|
+
verify_grant(SECRET, bad, now=1000)
|
|
86
|
+
|
|
87
|
+
def test_requires_claims_on_mint(self):
|
|
88
|
+
with self.assertRaises(MasqueradeError):
|
|
89
|
+
mint_grant(SECRET, real_user="", effective_user="x", pool="p")
|
|
90
|
+
with self.assertRaises(MasqueradeError):
|
|
91
|
+
mint_grant(SECRET, real_user="a", effective_user="", pool="p")
|
|
92
|
+
with self.assertRaises(MasqueradeError):
|
|
93
|
+
mint_grant(SECRET, real_user="a", effective_user="b", pool="")
|
|
94
|
+
|
|
95
|
+
def test_requires_secret(self):
|
|
96
|
+
with self.assertRaises(MasqueradeError):
|
|
97
|
+
mint_grant(None, real_user="a", effective_user="b", pool="p")
|
|
98
|
+
with self.assertRaises(MasqueradeError):
|
|
99
|
+
verify_grant("", "a.b", now=1000)
|
|
100
|
+
|
|
101
|
+
def test_distinct_jti_per_grant(self):
|
|
102
|
+
a = mint_grant(SECRET, real_user="a", effective_user="b", pool="p", now=1)
|
|
103
|
+
b = mint_grant(SECRET, real_user="a", effective_user="b", pool="p", now=1)
|
|
104
|
+
self.assertNotEqual(
|
|
105
|
+
verify_grant(SECRET, a, now=1)["jti"],
|
|
106
|
+
verify_grant(SECRET, b, now=1)["jti"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
unittest.main()
|