matrx-orm 3.1.38__tar.gz → 3.1.40__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.
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/PKG-INFO +1 -1
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/FEATURE.md +53 -5
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/__init__.py +12 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/items.py +375 -10
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/org_service.py +38 -3
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/pyproject.toml +1 -1
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_secrets_battery.py +193 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/.gitignore +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/AGENTS.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/BASE_CLASS_METHODS.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/CLAUDE.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/MODEL_API.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/database/orm/extended/managers/ai_model_base.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/docs/migrations.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/async_postgresql.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/base_adapter.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/postgresql.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/adapters/supabase_adapter.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/admin/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/admin/router.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/api/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/api/auth.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/api/config.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/api/handlers.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/api/protocol.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/api/server.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/associations.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/cache_debug.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/catalog.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/postgres_connection.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/postgrest.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/supabase_auth.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/supabase_config.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/client/supabase_manager.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/EXTENDED-TASK.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/TASKS.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/async_db_manager.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/base.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/config.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/connections.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/db_function.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/diagnostics.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/expressions.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/extended.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/fields.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/introspection.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/listen.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/loop_filters.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/model_dto.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/model_view.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/paginator.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/pool_maintenance.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/pydantic_bridge.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/registry.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/relations.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/resilience.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/rls_session.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/signals.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/tls.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/transaction.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/types.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/core/write_queue.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/entity.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/error_handling.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/exceptions.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/extended/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/extended/app_error_handler.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/identity_rule.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/middleware/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/middleware/base.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/cli.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/ddl.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/diff.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/executor.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/integration.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/loader.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/operations.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/state.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/migrations/table_filter.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/bulk_update_values.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/conflict.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/conflict_writes.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/create.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/db_functions.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/delete.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/dynamic_admin.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/dynamic_crud.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/graph_walk.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/latest_rows.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/queue_claim.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/read.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/relational_backfill.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/staging_load.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/operations/update.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/py.typed +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/python_sql/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/python_sql/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/python_sql/db_objects.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/query/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/query/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/query/builder.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/query/executor.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/code_handler.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/codegen_writer.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/columns.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/common.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/diff_preview.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/generator.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/package_wiring.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/relationships.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/runner.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/schema.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/schema_manager.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/tables.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/schema_builder/views.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/crypto.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/model.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/secrets_battery/service.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/coalesce.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/dag.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/errors.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/fallback.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/flush.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/lifecycle.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/managed.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/op.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/reads.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/session.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/session/telemetry.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/executor.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/queries.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/registry.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/types.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/sql_executor/utils.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/state.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/utils/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/utils/sql_utils.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/matrx_orm/utils/type_converters.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/scripts/git-branches.sh +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/scripts/publish.sh +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/scripts/release.sh +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/conftest.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_admin_db_columns.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_admin_search_negation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_admin_search_sentinels.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_advisory_lock.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_agent_message.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_api_auth.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_api_config.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_api_handlers.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_api_protocol.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_array_agg_order_by.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_associations.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_bulk_update_by_pk.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_cache_debug.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_call_function.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_case_when.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_catalog.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_composite_pk_filter.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_composite_pk_write_paths.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_config.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_conflict_target.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_conflict_writes.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_connection_codecs.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_connection_poison_guard.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_connection_primitives.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_count_composite_distinct.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_db_function_field.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_ddl_generator.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_derived_table.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_dirty_tracking.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_dynamic_crud.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_exceptions.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_expression_primitives.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_fields.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_from_alias.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_fts.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_func_expression_args.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_graph_walk.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_having_annotate_alias.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_introspect_rls_policies.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_join_and_aggregate.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_latest_rows.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_listen_notify.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_managed_write_guard.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_matrx_entity.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_migration_diff_types.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_migration_loader.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_model_instance.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_model_meta.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_order_by_expression.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_patch_jsonb_path.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_pool_loop_recreation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_pool_maintenance.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_postgrest_filters.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_pydantic_bridge.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_query_builder.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_query_error_detail.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_query_executor_sql.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_query_timeout.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_queue_claim.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_registry.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_registry_multi_database.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_registry_multi_schema.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_relational_backfill.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_relations.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_resilience.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_schema_exists.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_session_advisory_lock.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_state_cache.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_subquery_filter_raw.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_subquery_in_filter.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_supabase_auth.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_supabase_config.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_tls.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_typed_join_projection.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_typed_predicates.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_update_case_expression.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_update_subquery.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_upsert_default_leak.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_upsert_with_conflict.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_values_jsonb_decode.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_watched_lifecycle.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level1/test_write_retry_semantics.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/MODULE_README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/conftest.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_bulk_ops.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_cache_integration.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_crud.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_foreign_keys.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_m2m.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_manager.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_migrations_live.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_query_execution.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/level2/test_schema_diff.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/.env.example +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/generate.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/generated/.gitkeep +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/matrx_orm.yaml +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project/test_schema_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/.env.example +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/README.md +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/client_example.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/client_example.ts +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/client_supabase_example.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/sample_project_desktop/server.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/entity_tests.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_base_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_composite_pk_fk_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_composite_unique_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_dto_identity_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_entity_capability_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_generate_schema.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_junction_analysis.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_multi_schema_output.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_sql_expression_defaults.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema/test_view_generation.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/schema_builder/test_focused_generation_plan.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/__init__.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/conftest.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_cache_no_downgrade.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_capture_poison_proof.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_coalesce.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_dag.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_disk_spill_fallback.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_flush_individual_fallback.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_governed_write.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_managed.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_op_enqueue_site.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_reads.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/session/test_session.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_database_name_alias.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_db_functions.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_model_cls_refactor.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_no_blocking_asyncpg_tls.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_sql_param_casts.py +0 -0
- {matrx_orm-3.1.38 → matrx_orm-3.1.40}/tests/test_sync_twins_guard.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matrx-orm
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.40
|
|
4
4
|
Summary: Async-first PostgreSQL ORM with bidirectional migrations, schema introspection, many-to-many relationships, and built-in state caching
|
|
5
5
|
Project-URL: Homepage, https://github.com/AI-Matrix-Engine/aidream-current
|
|
6
6
|
Project-URL: Repository, https://github.com/AI-Matrix-Engine/aidream-current
|
|
@@ -37,16 +37,60 @@ row whose owner columns don't match its item's owner.
|
|
|
37
37
|
`editable=false` blocks human value changes (`FieldNotEditableError`); the owning
|
|
38
38
|
service rotates via `system_rotation=True` / `rotate_credential_fields`.
|
|
39
39
|
|
|
40
|
+
**`inject_into_sandbox=true` REQUIRES an env alias — refused otherwise.** A
|
|
41
|
+
container environment is a NAME→value map, so a field with `key IS NULL` has
|
|
42
|
+
nowhere to land and bulk resolution drops it. Accepting the flag anyway meant a
|
|
43
|
+
user could switch "inject into sandbox" on and get nothing, forever, with no
|
|
44
|
+
error — the silent-failure class this guard ends. Three layers, each sufficient
|
|
45
|
+
alone, each loud:
|
|
46
|
+
|
|
47
|
+
1. **Write refusal** — `create_credential_bundle` / `add_credential_field` /
|
|
48
|
+
`update_field_metadata` raise `SandboxInjectionWithoutEnvKeyError` (a
|
|
49
|
+
`ValueError`, so hosts answer 422 with the reason). `update_field_metadata`
|
|
50
|
+
judges the POST-patch state, so *clearing the alias of an already-injecting
|
|
51
|
+
field* is refused the same way. Setting `env_key` and the flag in ONE patch
|
|
52
|
+
is the supported fix and always passes.
|
|
53
|
+
2. **Resolution scream** — `_keyed_for_sandbox` logs an ERROR naming the row ids
|
|
54
|
+
and skips them, so anything that got in around layer 1 is visible.
|
|
55
|
+
3. **DB CHECK** `user_secrets_sandbox_injection_needs_key` (migration 0265) makes
|
|
56
|
+
the state unrepresentable; the column default moved `true` → `false` so the
|
|
57
|
+
database stops manufacturing violations on keyless inserts.
|
|
58
|
+
|
|
40
59
|
Item surface: `create_credential_item` · `create_credential_bundle` (item + N
|
|
41
60
|
fields, ONE transaction) · `get/list/update/delete_credential_item` (delete =
|
|
42
61
|
soft, atomic across item + fields + grants) · `add_credential_field` ·
|
|
43
62
|
`update_credential_field_value` · `update_field_metadata` (metadata-only patch — value/version untouched; sealed is a one-way door: TO sealed ok, FROM sealed never; env-alias regex + per-principal uniqueness) · `rotate_credential_fields` (atomic multi-field)
|
|
44
63
|
· `delete_credential_field` · stable resolution `resolve_field_by_id` /
|
|
45
64
|
`resolve_field(item_id, field_key)` / `resolve_item_fields` · `reveal_field` ·
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
`
|
|
49
|
-
|
|
65
|
+
`add_item_grant` / `update_item_grant` / `remove_item_grant` / `list_item_grants`
|
|
66
|
+
· `set_item_grants` · `list_granted_credential_items` ·
|
|
67
|
+
`transfer_credential_item` (owner flip, no value copy, references survive) ·
|
|
68
|
+
`fork_credential_item` (independent copy) · `resolve_legacy_env_key` (compat
|
|
69
|
+
read that SCREAMS in logs until call sites migrate to stable references).
|
|
70
|
+
|
|
71
|
+
**Grants: use the single-grant operations.** `add_item_grant` /
|
|
72
|
+
`update_item_grant` / `remove_item_grant` change ONE recipient and leave the
|
|
73
|
+
rest alone. `set_item_grants` REPLACES the whole set (delete + recreate) and
|
|
74
|
+
drops every grantee when `access_mode='all_members'` — it survives for the org
|
|
75
|
+
access-mode flip, but building new callers on it is how a share edit silently
|
|
76
|
+
revokes someone. `list_granted_credential_items(user_id)` is the
|
|
77
|
+
"shared with me" read.
|
|
78
|
+
|
|
79
|
+
`transfer_credential_item` clears prior grants by default (`clear_grants=True`)
|
|
80
|
+
and writes BOTH `item_transferred` and `item_transferred_out`, so the losing
|
|
81
|
+
owner's audit shows it too. Its `to_user_id` accepts any user — deciding who may
|
|
82
|
+
transfer to whom (resolved from an exact email) is the HOST's job, per the
|
|
83
|
+
battery's no-tenancy rule.
|
|
84
|
+
|
|
85
|
+
**Destination-login metadata (2026-07-26, credential-sharing PLAN).** Items also
|
|
86
|
+
carry PLAINTEXT, deliberately-unencrypted columns: `login_urls` (`text[]`),
|
|
87
|
+
`uri_match_mode` (`host|exact|never`), `notes`, `non_secret_fields` (ordered
|
|
88
|
+
`[{key,label,value}]`), `browser_fill_enabled`. This module validates their
|
|
89
|
+
shape — URLs must be http/https, `non_secret_fields` keys match `FIELD_KEY_RE`
|
|
90
|
+
and are unique, entries carry only `{key,label,value}` — because it is the only
|
|
91
|
+
write path. `handling` does NOT apply to them: whether a value is encrypted is
|
|
92
|
+
decided by the catalog field's `storage_class`, never by handling. Never move a
|
|
93
|
+
secret into these columns to make it "easier to read".
|
|
50
94
|
|
|
51
95
|
## Consumer contract
|
|
52
96
|
|
|
@@ -93,7 +137,10 @@ EXCLUDES `sealed` fields, so a browser-facing caller can never receive sealed
|
|
|
93
137
|
plaintext through an env dump. Only trusted execution (service-token sandbox
|
|
94
138
|
injection, in-process context hydration, provider adapters) passes `True`.
|
|
95
139
|
Rows with `key IS NULL` (item fields without an env alias) never resolve into
|
|
96
|
-
an environment in either mode
|
|
140
|
+
an environment in either mode — silently when nothing promised otherwise (a
|
|
141
|
+
keyless item field is normal), but **LOUDLY when `for_sandbox=True`**: such a row
|
|
142
|
+
is flagged for injection and cannot deliver, which is a defect (see the
|
|
143
|
+
handling doctrine above).
|
|
97
144
|
|
|
98
145
|
**Legacy key-based writes respect item discipline:** `create_user_secret(upsert=True)`,
|
|
99
146
|
`update_user_secret`, and `delete_user_secret` REFUSE a target row that is
|
|
@@ -191,6 +238,7 @@ create, reveal, sealed denial, editable gate, rotate, item grants, soft delete).
|
|
|
191
238
|
|
|
192
239
|
## Change Log
|
|
193
240
|
|
|
241
|
+
- 2026-07-26 — Closed the silent sandbox-injection gap: `SandboxInjectionWithoutEnvKeyError` at every field write path, a loud ERROR-and-skip in `for_sandbox` bulk resolution, and DB CHECK `user_secrets_sandbox_injection_needs_key` (+ column default `true`→`false`). Live at the time of the fix: 0 broken rows, but 61 keyless item fields were one toggle away from it.
|
|
194
242
|
- 2026-07-23 — `update_field_metadata` primitive (metadata-only, version-preserving; `users.bump_user_secret_value_version` verified value-conditional live — no trigger change needed).
|
|
195
243
|
- 2026-07-23 — Adversarial-review hardening: include_sealed-aware bulk env resolution (+ keyless-row exclusion), ItemManagedSecretError legacy-write guards + legacy audit rows, ItemGrantSpec per-grantee grants, reveal liveness filters, rotate liveness re-check + audits inside owning transactions.
|
|
196
244
|
- 2026-07-23 — Phase 2: item layer (`items.py` — bundles, handling modes, stable resolution, item grants, transfer/fork), rotation primitives (`fernet_for_key`/`multi_fernet_for_keys`) + `scripts/rotate_credentials_key.py` (closes the no-rotation-script ticket), migration 0236 grants fixup (item-level grants possible).
|
|
@@ -41,14 +41,17 @@ from .items import (
|
|
|
41
41
|
HANDLING_MODES,
|
|
42
42
|
ITEM_SOURCES,
|
|
43
43
|
ITEM_STATUSES,
|
|
44
|
+
URI_MATCH_MODES,
|
|
44
45
|
CredentialFieldRow,
|
|
45
46
|
CredentialItemNotFoundError,
|
|
46
47
|
CredentialItemRow,
|
|
47
48
|
FieldNotEditableError,
|
|
48
49
|
FieldSpec,
|
|
49
50
|
ItemGrantSpec,
|
|
51
|
+
SandboxInjectionWithoutEnvKeyError,
|
|
50
52
|
SealedFieldError,
|
|
51
53
|
add_credential_field,
|
|
54
|
+
add_item_grant,
|
|
52
55
|
create_credential_bundle,
|
|
53
56
|
create_credential_item,
|
|
54
57
|
delete_credential_field,
|
|
@@ -56,7 +59,10 @@ from .items import (
|
|
|
56
59
|
fork_credential_item,
|
|
57
60
|
get_credential_item,
|
|
58
61
|
list_credential_items,
|
|
62
|
+
list_granted_credential_items,
|
|
59
63
|
list_item_grants,
|
|
64
|
+
remove_item_grant,
|
|
65
|
+
update_item_grant,
|
|
60
66
|
resolve_field,
|
|
61
67
|
resolve_field_by_id,
|
|
62
68
|
resolve_item_fields,
|
|
@@ -145,8 +151,11 @@ __all__ = [
|
|
|
145
151
|
"FieldNotEditableError",
|
|
146
152
|
"FieldSpec",
|
|
147
153
|
"ItemGrantSpec",
|
|
154
|
+
"SandboxInjectionWithoutEnvKeyError",
|
|
148
155
|
"SealedFieldError",
|
|
156
|
+
"URI_MATCH_MODES",
|
|
149
157
|
"add_credential_field",
|
|
158
|
+
"add_item_grant",
|
|
150
159
|
"create_credential_bundle",
|
|
151
160
|
"create_credential_item",
|
|
152
161
|
"delete_credential_field",
|
|
@@ -154,7 +163,10 @@ __all__ = [
|
|
|
154
163
|
"fork_credential_item",
|
|
155
164
|
"get_credential_item",
|
|
156
165
|
"list_credential_items",
|
|
166
|
+
"list_granted_credential_items",
|
|
157
167
|
"list_item_grants",
|
|
168
|
+
"remove_item_grant",
|
|
169
|
+
"update_item_grant",
|
|
158
170
|
"resolve_field",
|
|
159
171
|
"resolve_field_by_id",
|
|
160
172
|
"resolve_item_fields",
|
|
@@ -42,6 +42,7 @@ snake, stable semantic identity of a field within its item."""
|
|
|
42
42
|
|
|
43
43
|
HANDLING_MODES: tuple[str, ...] = ("visible", "revealable", "sealed")
|
|
44
44
|
ITEM_STATUSES: tuple[str, ...] = ("active", "disabled", "expired", "pending_verification")
|
|
45
|
+
URI_MATCH_MODES: tuple[str, ...] = ("host", "exact", "never")
|
|
45
46
|
ITEM_SOURCES: tuple[str, ...] = (
|
|
46
47
|
"manual",
|
|
47
48
|
"env_import",
|
|
@@ -68,6 +69,18 @@ class FieldNotEditableError(PermissionError):
|
|
|
68
69
|
(``rotate_credential_fields``), never by hand."""
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
class SandboxInjectionWithoutEnvKeyError(ValueError):
|
|
73
|
+
"""``inject_into_sandbox=true`` on a field that has no environment-variable
|
|
74
|
+
alias (``key``). A container environment is a NAME→value map: with no name
|
|
75
|
+
the value has nowhere to land, so the flag is a promise the platform cannot
|
|
76
|
+
keep — the field would silently never appear in any sandbox.
|
|
77
|
+
|
|
78
|
+
Refused LOUDLY at every write path instead of accepted-and-dropped (hosts
|
|
79
|
+
map ``ValueError`` → 422). Set an env alias in the same request (the vault
|
|
80
|
+
field-metadata patch takes ``env_key`` and ``inject_into_sandbox``
|
|
81
|
+
together) or leave injection off."""
|
|
82
|
+
|
|
83
|
+
|
|
71
84
|
# ── Shapes ────────────────────────────────────────────────────────────────
|
|
72
85
|
|
|
73
86
|
|
|
@@ -137,6 +150,13 @@ class CredentialItemRow:
|
|
|
137
150
|
source: str
|
|
138
151
|
access_mode: str
|
|
139
152
|
lifecycle: dict[str, Any]
|
|
153
|
+
# Destination-login PLAINTEXT metadata (credential-sharing PLAN, 2026-07-26).
|
|
154
|
+
# Deliberately unencrypted; protected by the same ownership/grant/org RLS.
|
|
155
|
+
login_urls: list[str]
|
|
156
|
+
uri_match_mode: str
|
|
157
|
+
notes: str | None
|
|
158
|
+
non_secret_fields: list[dict[str, str]]
|
|
159
|
+
browser_fill_enabled: bool
|
|
140
160
|
created_by: str | None
|
|
141
161
|
updated_by: str | None
|
|
142
162
|
created_at: str
|
|
@@ -167,6 +187,66 @@ def _validate_handling(handling: str) -> None:
|
|
|
167
187
|
raise ValueError(f"handling must be one of {HANDLING_MODES}; got {handling!r}")
|
|
168
188
|
|
|
169
189
|
|
|
190
|
+
def _validate_sandbox_injectable(
|
|
191
|
+
*,
|
|
192
|
+
field_key: str | None,
|
|
193
|
+
env_key: str | None,
|
|
194
|
+
inject_into_sandbox: bool,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""LAYER 1 of the no-silent-injection guard: a field may only be flagged
|
|
197
|
+
for sandbox injection while it carries an env alias. See
|
|
198
|
+
:class:`SandboxInjectionWithoutEnvKeyError`. Layer 2 is the loud skip in
|
|
199
|
+
``org_service`` bulk resolution; layer 3 is the DB CHECK
|
|
200
|
+
``user_secrets_sandbox_injection_needs_key``."""
|
|
201
|
+
if inject_into_sandbox and not env_key:
|
|
202
|
+
raise SandboxInjectionWithoutEnvKeyError(
|
|
203
|
+
f"field {field_key or '<unnamed>'!r} cannot be injected into sandboxes without "
|
|
204
|
+
"an environment-variable alias (env_key) — a container env has no name to bind "
|
|
205
|
+
"the value to, so the flag would silently do nothing. Set env_key in the same "
|
|
206
|
+
"request, or leave inject_into_sandbox off."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validate_uri_match_mode(mode: str) -> None:
|
|
211
|
+
if mode not in URI_MATCH_MODES:
|
|
212
|
+
raise ValueError(f"uri_match_mode must be one of {URI_MATCH_MODES}; got {mode!r}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_login_urls(urls: list[str]) -> None:
|
|
216
|
+
if not isinstance(urls, list):
|
|
217
|
+
raise ValueError("login_urls must be a list of URLs")
|
|
218
|
+
for url in urls:
|
|
219
|
+
if not isinstance(url, str) or not url.strip():
|
|
220
|
+
raise ValueError("login_urls entries must be non-empty strings")
|
|
221
|
+
lowered = url.strip().lower()
|
|
222
|
+
if not (lowered.startswith("https://") or lowered.startswith("http://")):
|
|
223
|
+
raise ValueError(f"login URL must start with https:// or http://; got {url!r}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_non_secret_fields(entries: list[dict[str, str]]) -> None:
|
|
227
|
+
"""Ordered plaintext ``{key, label, value}`` entries. The DB pins the array
|
|
228
|
+
container; the exact entry shape is pinned here (the only write path)."""
|
|
229
|
+
if not isinstance(entries, list):
|
|
230
|
+
raise ValueError("non_secret_fields must be a list")
|
|
231
|
+
seen: set[str] = set()
|
|
232
|
+
for entry in entries:
|
|
233
|
+
if not isinstance(entry, dict):
|
|
234
|
+
raise ValueError("each non_secret_fields entry must be an object")
|
|
235
|
+
extra = set(entry) - {"key", "label", "value"}
|
|
236
|
+
if extra:
|
|
237
|
+
raise ValueError(f"non_secret_fields entry has unknown keys {sorted(extra)}")
|
|
238
|
+
key = entry.get("key")
|
|
239
|
+
if not isinstance(key, str) or not re.match(FIELD_KEY_RE, key):
|
|
240
|
+
raise ValueError(f"non_secret_fields entry key must match {FIELD_KEY_RE}; got {key!r}")
|
|
241
|
+
if key in seen:
|
|
242
|
+
raise ValueError(f"duplicate non_secret_fields key {key!r}")
|
|
243
|
+
seen.add(key)
|
|
244
|
+
if not isinstance(entry.get("label"), str) or not entry["label"]:
|
|
245
|
+
raise ValueError(f"non_secret_fields entry {key!r} needs a non-empty label")
|
|
246
|
+
if not isinstance(entry.get("value"), str):
|
|
247
|
+
raise ValueError(f"non_secret_fields entry {key!r} value must be a string")
|
|
248
|
+
|
|
249
|
+
|
|
170
250
|
def _iso(value: Any) -> str:
|
|
171
251
|
if isinstance(value, datetime):
|
|
172
252
|
return value.isoformat()
|
|
@@ -187,6 +267,11 @@ _ITEM_FIELDS = (
|
|
|
187
267
|
"source",
|
|
188
268
|
"access_mode",
|
|
189
269
|
"lifecycle",
|
|
270
|
+
"login_urls",
|
|
271
|
+
"uri_match_mode",
|
|
272
|
+
"notes",
|
|
273
|
+
"non_secret_fields",
|
|
274
|
+
"browser_fill_enabled",
|
|
190
275
|
"created_by",
|
|
191
276
|
"updated_by",
|
|
192
277
|
"created_at",
|
|
@@ -225,6 +310,11 @@ def _item_row(raw: dict[str, Any], fields: list[CredentialFieldRow]) -> Credenti
|
|
|
225
310
|
source=str(raw["source"]),
|
|
226
311
|
access_mode=str(raw.get("access_mode") or "all_members"),
|
|
227
312
|
lifecycle=dict(raw.get("lifecycle") or {}),
|
|
313
|
+
login_urls=list(raw.get("login_urls") or []),
|
|
314
|
+
uri_match_mode=str(raw.get("uri_match_mode") or "host"),
|
|
315
|
+
notes=raw.get("notes"),
|
|
316
|
+
non_secret_fields=list(raw.get("non_secret_fields") or []),
|
|
317
|
+
browser_fill_enabled=bool(raw.get("browser_fill_enabled", False)),
|
|
228
318
|
created_by=str(raw["created_by"]) if raw.get("created_by") else None,
|
|
229
319
|
updated_by=str(raw["updated_by"]) if raw.get("updated_by") else None,
|
|
230
320
|
created_at=_iso(raw["created_at"]),
|
|
@@ -275,6 +365,33 @@ async def _require_item_raw(item_id: str) -> dict[str, Any]:
|
|
|
275
365
|
# ── Item CRUD ─────────────────────────────────────────────────────────────
|
|
276
366
|
|
|
277
367
|
|
|
368
|
+
def _metadata_kwargs(
|
|
369
|
+
*,
|
|
370
|
+
login_urls: list[str] | None,
|
|
371
|
+
uri_match_mode: str | None,
|
|
372
|
+
notes: str | None,
|
|
373
|
+
non_secret_fields: list[dict[str, str]] | None,
|
|
374
|
+
browser_fill_enabled: bool | None,
|
|
375
|
+
) -> dict[str, Any]:
|
|
376
|
+
"""Validate + shape the destination-login plaintext metadata for a create.
|
|
377
|
+
Omitted (None) values fall back to the column defaults."""
|
|
378
|
+
out: dict[str, Any] = {}
|
|
379
|
+
if login_urls is not None:
|
|
380
|
+
_validate_login_urls(login_urls)
|
|
381
|
+
out["login_urls"] = [u.strip() for u in login_urls]
|
|
382
|
+
if uri_match_mode is not None:
|
|
383
|
+
_validate_uri_match_mode(uri_match_mode)
|
|
384
|
+
out["uri_match_mode"] = uri_match_mode
|
|
385
|
+
if notes is not None:
|
|
386
|
+
out["notes"] = notes
|
|
387
|
+
if non_secret_fields is not None:
|
|
388
|
+
_validate_non_secret_fields(non_secret_fields)
|
|
389
|
+
out["non_secret_fields"] = non_secret_fields
|
|
390
|
+
if browser_fill_enabled is not None:
|
|
391
|
+
out["browser_fill_enabled"] = bool(browser_fill_enabled)
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
278
395
|
async def create_credential_item(
|
|
279
396
|
*,
|
|
280
397
|
user_id: str | None = None,
|
|
@@ -288,6 +405,11 @@ async def create_credential_item(
|
|
|
288
405
|
source: str = "manual",
|
|
289
406
|
access_mode: str = "all_members",
|
|
290
407
|
lifecycle: dict[str, Any] | None = None,
|
|
408
|
+
login_urls: list[str] | None = None,
|
|
409
|
+
uri_match_mode: str | None = None,
|
|
410
|
+
notes: str | None = None,
|
|
411
|
+
non_secret_fields: list[dict[str, str]] | None = None,
|
|
412
|
+
browser_fill_enabled: bool | None = None,
|
|
291
413
|
actor_id: str | None = None,
|
|
292
414
|
) -> CredentialItemRow:
|
|
293
415
|
"""Create an empty item (no fields). Prefer :func:`create_credential_bundle`
|
|
@@ -310,6 +432,13 @@ async def create_credential_item(
|
|
|
310
432
|
source=source,
|
|
311
433
|
access_mode=access_mode,
|
|
312
434
|
lifecycle=lifecycle or {},
|
|
435
|
+
**_metadata_kwargs(
|
|
436
|
+
login_urls=login_urls,
|
|
437
|
+
uri_match_mode=uri_match_mode,
|
|
438
|
+
notes=notes,
|
|
439
|
+
non_secret_fields=non_secret_fields,
|
|
440
|
+
browser_fill_enabled=browser_fill_enabled,
|
|
441
|
+
),
|
|
313
442
|
created_by=actor_id,
|
|
314
443
|
updated_by=actor_id,
|
|
315
444
|
)
|
|
@@ -340,6 +469,11 @@ async def create_credential_bundle(
|
|
|
340
469
|
source: str = "manual",
|
|
341
470
|
access_mode: str = "all_members",
|
|
342
471
|
lifecycle: dict[str, Any] | None = None,
|
|
472
|
+
login_urls: list[str] | None = None,
|
|
473
|
+
uri_match_mode: str | None = None,
|
|
474
|
+
notes: str | None = None,
|
|
475
|
+
non_secret_fields: list[dict[str, str]] | None = None,
|
|
476
|
+
browser_fill_enabled: bool | None = None,
|
|
343
477
|
actor_id: str | None = None,
|
|
344
478
|
) -> CredentialItemRow:
|
|
345
479
|
"""**The atomic create path**: item + N encrypted fields in ONE
|
|
@@ -355,6 +489,11 @@ async def create_credential_bundle(
|
|
|
355
489
|
for spec in fields:
|
|
356
490
|
_validate_field_key(spec.field_key)
|
|
357
491
|
_validate_handling(spec.handling)
|
|
492
|
+
_validate_sandbox_injectable(
|
|
493
|
+
field_key=spec.field_key,
|
|
494
|
+
env_key=spec.env_key,
|
|
495
|
+
inject_into_sandbox=spec.inject_into_sandbox,
|
|
496
|
+
)
|
|
358
497
|
if not isinstance(spec.value, str):
|
|
359
498
|
raise ValueError(f"field {spec.field_key!r}: value must be a string")
|
|
360
499
|
if spec.field_key in seen:
|
|
@@ -375,6 +514,13 @@ async def create_credential_bundle(
|
|
|
375
514
|
source=source,
|
|
376
515
|
access_mode=access_mode,
|
|
377
516
|
lifecycle=lifecycle or {},
|
|
517
|
+
**_metadata_kwargs(
|
|
518
|
+
login_urls=login_urls,
|
|
519
|
+
uri_match_mode=uri_match_mode,
|
|
520
|
+
notes=notes,
|
|
521
|
+
non_secret_fields=non_secret_fields,
|
|
522
|
+
browser_fill_enabled=browser_fill_enabled,
|
|
523
|
+
),
|
|
378
524
|
created_by=actor_id,
|
|
379
525
|
updated_by=actor_id,
|
|
380
526
|
)
|
|
@@ -457,9 +603,16 @@ async def update_credential_item(
|
|
|
457
603
|
status: str | None = None,
|
|
458
604
|
provider_key: str | None = None,
|
|
459
605
|
lifecycle: dict[str, Any] | None = None,
|
|
606
|
+
login_urls: list[str] | None = None,
|
|
607
|
+
uri_match_mode: str | None = None,
|
|
608
|
+
notes: Any = None,
|
|
609
|
+
clear_notes: bool = False,
|
|
610
|
+
non_secret_fields: list[dict[str, str]] | None = None,
|
|
611
|
+
browser_fill_enabled: bool | None = None,
|
|
460
612
|
) -> CredentialItemRow:
|
|
461
|
-
"""Patch item metadata. ``None`` fields are left alone
|
|
462
|
-
field rows — this never
|
|
613
|
+
"""Patch item metadata. ``None`` fields are left alone (``clear_notes=True``
|
|
614
|
+
nulls the plaintext notes). Values live on the field rows — this never
|
|
615
|
+
touches ciphertext."""
|
|
463
616
|
if status is not None and status not in ITEM_STATUSES:
|
|
464
617
|
raise ValueError(f"status must be one of {ITEM_STATUSES}; got {status!r}")
|
|
465
618
|
updates: dict[str, Any] = {"updated_by": actor_id, "updated_at": datetime.now(UTC)}
|
|
@@ -475,6 +628,17 @@ async def update_credential_item(
|
|
|
475
628
|
updates["provider_key"] = provider_key
|
|
476
629
|
if lifecycle is not None:
|
|
477
630
|
updates["lifecycle"] = lifecycle
|
|
631
|
+
updates.update(
|
|
632
|
+
_metadata_kwargs(
|
|
633
|
+
login_urls=login_urls,
|
|
634
|
+
uri_match_mode=uri_match_mode,
|
|
635
|
+
notes=None if clear_notes else notes,
|
|
636
|
+
non_secret_fields=non_secret_fields,
|
|
637
|
+
browser_fill_enabled=browser_fill_enabled,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
if clear_notes:
|
|
641
|
+
updates["notes"] = None
|
|
478
642
|
result = await CredentialItem.update_where(
|
|
479
643
|
{"id": item_id, "deleted_at__isnull": True}, **updates
|
|
480
644
|
)
|
|
@@ -540,6 +704,11 @@ async def add_credential_field(
|
|
|
540
704
|
the item so field-level scoping stays consistent)."""
|
|
541
705
|
_validate_field_key(spec.field_key)
|
|
542
706
|
_validate_handling(spec.handling)
|
|
707
|
+
_validate_sandbox_injectable(
|
|
708
|
+
field_key=spec.field_key,
|
|
709
|
+
env_key=spec.env_key,
|
|
710
|
+
inject_into_sandbox=spec.inject_into_sandbox,
|
|
711
|
+
)
|
|
543
712
|
if not isinstance(spec.value, str):
|
|
544
713
|
raise ValueError("value must be a string")
|
|
545
714
|
raw = await _require_item_raw(item_id)
|
|
@@ -710,6 +879,19 @@ async def update_field_metadata(
|
|
|
710
879
|
"a one-way door; rotate/replace the field instead of unsealing it"
|
|
711
880
|
)
|
|
712
881
|
|
|
882
|
+
# The POST-patch state is what matters: enabling injection on a keyless
|
|
883
|
+
# field AND clearing the alias of an already-injecting field are the
|
|
884
|
+
# same silent breakage from opposite directions. Both are refused.
|
|
885
|
+
_validate_sandbox_injectable(
|
|
886
|
+
field_key=raw.get("field_key") or raw.get("key"),
|
|
887
|
+
env_key=(raw.get("key") if env_key is _UNSET else env_key),
|
|
888
|
+
inject_into_sandbox=(
|
|
889
|
+
bool(raw.get("inject_into_sandbox"))
|
|
890
|
+
if inject_into_sandbox is None
|
|
891
|
+
else inject_into_sandbox
|
|
892
|
+
),
|
|
893
|
+
)
|
|
894
|
+
|
|
713
895
|
updates: dict[str, Any] = {"updated_by": actor_id, "updated_at": datetime.now(UTC)}
|
|
714
896
|
if inject_into_sandbox is not None:
|
|
715
897
|
updates["inject_into_sandbox"] = inject_into_sandbox
|
|
@@ -1000,20 +1182,188 @@ async def list_item_grants(item_id: str) -> list[dict[str, Any]]:
|
|
|
1000
1182
|
]
|
|
1001
1183
|
|
|
1002
1184
|
|
|
1185
|
+
async def add_item_grant(
|
|
1186
|
+
item_id: str,
|
|
1187
|
+
*,
|
|
1188
|
+
user_id: str,
|
|
1189
|
+
can_use: bool = True,
|
|
1190
|
+
can_manage: bool = False,
|
|
1191
|
+
actor_id: str | None = None,
|
|
1192
|
+
) -> dict[str, Any]:
|
|
1193
|
+
"""Add (or refresh) ONE item-level grant without touching any other
|
|
1194
|
+
recipient — the explicit-operation replacement for the destructive
|
|
1195
|
+
``set_item_grants`` overwrite. The HOST verifies the actor may share and
|
|
1196
|
+
that the recipient is legitimate first."""
|
|
1197
|
+
if not user_id:
|
|
1198
|
+
raise ValueError("grant user_id is required")
|
|
1199
|
+
await _require_item_raw(item_id)
|
|
1200
|
+
existing = await UserSecretGrant.filter(
|
|
1201
|
+
credential_item_id=item_id, user_id=user_id
|
|
1202
|
+
).values("id")
|
|
1203
|
+
if existing:
|
|
1204
|
+
grant_id = str(existing[0]["id"])
|
|
1205
|
+
await UserSecretGrant.update_where(
|
|
1206
|
+
{"id": grant_id},
|
|
1207
|
+
can_use=can_use,
|
|
1208
|
+
can_manage=can_manage,
|
|
1209
|
+
granted_by=actor_id,
|
|
1210
|
+
)
|
|
1211
|
+
action = "grant_changed"
|
|
1212
|
+
else:
|
|
1213
|
+
instance = await UserSecretGrant.create(
|
|
1214
|
+
credential_item_id=item_id,
|
|
1215
|
+
user_secret_id=None,
|
|
1216
|
+
user_id=user_id,
|
|
1217
|
+
can_use=can_use,
|
|
1218
|
+
can_manage=can_manage,
|
|
1219
|
+
granted_by=actor_id,
|
|
1220
|
+
)
|
|
1221
|
+
grant_id = str(instance.id)
|
|
1222
|
+
action = "grant_added"
|
|
1223
|
+
await record_secret_audit(
|
|
1224
|
+
action=action,
|
|
1225
|
+
actor_id=actor_id,
|
|
1226
|
+
metadata={
|
|
1227
|
+
"credential_item_id": item_id,
|
|
1228
|
+
"grant_id": grant_id,
|
|
1229
|
+
"recipient_user_id": user_id,
|
|
1230
|
+
"can_use": can_use,
|
|
1231
|
+
"can_manage": can_manage,
|
|
1232
|
+
},
|
|
1233
|
+
)
|
|
1234
|
+
rows = await UserSecretGrant.filter(id=grant_id).values(
|
|
1235
|
+
"id", "user_id", "can_use", "can_manage", "granted_by", "created_at"
|
|
1236
|
+
)
|
|
1237
|
+
r = rows[0]
|
|
1238
|
+
return {
|
|
1239
|
+
"id": str(r["id"]),
|
|
1240
|
+
"user_id": str(r["user_id"]),
|
|
1241
|
+
"can_use": bool(r["can_use"]),
|
|
1242
|
+
"can_manage": bool(r["can_manage"]),
|
|
1243
|
+
"granted_by": str(r["granted_by"]) if r.get("granted_by") else None,
|
|
1244
|
+
"created_at": _iso(r["created_at"]),
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
async def update_item_grant(
|
|
1249
|
+
item_id: str,
|
|
1250
|
+
grant_id: str,
|
|
1251
|
+
*,
|
|
1252
|
+
can_use: bool | None = None,
|
|
1253
|
+
can_manage: bool | None = None,
|
|
1254
|
+
actor_id: str | None = None,
|
|
1255
|
+
) -> dict[str, Any]:
|
|
1256
|
+
"""Change ONE grant's capabilities. The grant must belong to the item
|
|
1257
|
+
(path binding — same rule as fields)."""
|
|
1258
|
+
rows = await UserSecretGrant.filter(id=grant_id, credential_item_id=item_id).values(
|
|
1259
|
+
"id", "user_id", "can_use", "can_manage"
|
|
1260
|
+
)
|
|
1261
|
+
if not rows:
|
|
1262
|
+
raise LookupError(f"grant {grant_id} not found")
|
|
1263
|
+
updates: dict[str, Any] = {}
|
|
1264
|
+
if can_use is not None:
|
|
1265
|
+
updates["can_use"] = can_use
|
|
1266
|
+
if can_manage is not None:
|
|
1267
|
+
updates["can_manage"] = can_manage
|
|
1268
|
+
if updates:
|
|
1269
|
+
await UserSecretGrant.update_where({"id": grant_id}, **updates)
|
|
1270
|
+
await record_secret_audit(
|
|
1271
|
+
action="grant_changed",
|
|
1272
|
+
actor_id=actor_id,
|
|
1273
|
+
metadata={
|
|
1274
|
+
"credential_item_id": item_id,
|
|
1275
|
+
"grant_id": grant_id,
|
|
1276
|
+
"recipient_user_id": str(rows[0]["user_id"]),
|
|
1277
|
+
"can_use": updates.get("can_use", bool(rows[0]["can_use"])),
|
|
1278
|
+
"can_manage": updates.get("can_manage", bool(rows[0]["can_manage"])),
|
|
1279
|
+
},
|
|
1280
|
+
)
|
|
1281
|
+
out = await UserSecretGrant.filter(id=grant_id).values(
|
|
1282
|
+
"id", "user_id", "can_use", "can_manage", "granted_by", "created_at"
|
|
1283
|
+
)
|
|
1284
|
+
r = out[0]
|
|
1285
|
+
return {
|
|
1286
|
+
"id": str(r["id"]),
|
|
1287
|
+
"user_id": str(r["user_id"]),
|
|
1288
|
+
"can_use": bool(r["can_use"]),
|
|
1289
|
+
"can_manage": bool(r["can_manage"]),
|
|
1290
|
+
"granted_by": str(r["granted_by"]) if r.get("granted_by") else None,
|
|
1291
|
+
"created_at": _iso(r["created_at"]),
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
async def remove_item_grant(
|
|
1296
|
+
item_id: str,
|
|
1297
|
+
grant_id: str,
|
|
1298
|
+
*,
|
|
1299
|
+
actor_id: str | None = None,
|
|
1300
|
+
) -> None:
|
|
1301
|
+
"""Revoke ONE grant. Effective immediately — the RLS grantee policies and
|
|
1302
|
+
every capability check key on the row's existence."""
|
|
1303
|
+
rows = await UserSecretGrant.filter(id=grant_id, credential_item_id=item_id).values(
|
|
1304
|
+
"id", "user_id"
|
|
1305
|
+
)
|
|
1306
|
+
if not rows:
|
|
1307
|
+
raise LookupError(f"grant {grant_id} not found")
|
|
1308
|
+
await UserSecretGrant.delete_where(id=grant_id)
|
|
1309
|
+
await record_secret_audit(
|
|
1310
|
+
action="revoked",
|
|
1311
|
+
actor_id=actor_id,
|
|
1312
|
+
metadata={
|
|
1313
|
+
"credential_item_id": item_id,
|
|
1314
|
+
"grant_id": grant_id,
|
|
1315
|
+
"recipient_user_id": str(rows[0]["user_id"]),
|
|
1316
|
+
},
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
async def list_granted_credential_items(user_id: str) -> list[CredentialItemRow]:
|
|
1321
|
+
"""Items shared TO ``user_id`` through item-level grants (``can_use``),
|
|
1322
|
+
with masked fields — the backend twin of the frontend's grants-driven
|
|
1323
|
+
"Shared with me" query."""
|
|
1324
|
+
if not user_id:
|
|
1325
|
+
raise ValueError("user_id is required")
|
|
1326
|
+
grant_rows = await UserSecretGrant.filter(
|
|
1327
|
+
user_id=user_id, credential_item_id__isnull=False, can_use=True
|
|
1328
|
+
).values("credential_item_id")
|
|
1329
|
+
item_ids = sorted({str(r["credential_item_id"]) for r in grant_rows})
|
|
1330
|
+
if not item_ids:
|
|
1331
|
+
return []
|
|
1332
|
+
rows = (
|
|
1333
|
+
await CredentialItem.filter(id__in=item_ids, deleted_at__isnull=True)
|
|
1334
|
+
.order_by("display_name")
|
|
1335
|
+
.values(*_ITEM_FIELDS)
|
|
1336
|
+
)
|
|
1337
|
+
if not rows:
|
|
1338
|
+
return []
|
|
1339
|
+
fields = await _fields_for_items([str(r["id"]) for r in rows])
|
|
1340
|
+
return [_item_row(r, fields.get(str(r["id"]), [])) for r in rows]
|
|
1341
|
+
|
|
1342
|
+
|
|
1003
1343
|
async def transfer_credential_item(
|
|
1004
1344
|
item_id: str,
|
|
1005
1345
|
*,
|
|
1006
1346
|
to_user_id: str | None = None,
|
|
1007
1347
|
to_organization_id: str | None = None,
|
|
1008
1348
|
actor_id: str | None = None,
|
|
1349
|
+
clear_grants: bool = True,
|
|
1009
1350
|
) -> CredentialItemRow:
|
|
1010
1351
|
"""Move ownership in ONE audited transaction — item and every field flip
|
|
1011
1352
|
owner together, and every stable reference (item id, field ids, field
|
|
1012
|
-
keys) survives unchanged. No value is copied or re-encrypted.
|
|
1353
|
+
keys) survives unchanged. No value is copied or re-encrypted. Prior
|
|
1354
|
+
personal grants are CLEARED by default (a transfer is a clean handover,
|
|
1355
|
+
not a share), and both the old and the new owner get an audit entry."""
|
|
1013
1356
|
_validate_owner(to_user_id, to_organization_id)
|
|
1014
1357
|
raw = await _require_item_raw(item_id)
|
|
1358
|
+
from_user_id = str(raw["user_id"]) if raw.get("user_id") else None
|
|
1359
|
+
from_organization_id = str(raw["organization_id"]) if raw.get("organization_id") else None
|
|
1015
1360
|
now = datetime.now(UTC)
|
|
1016
1361
|
async with transaction(PACKAGE_DB_NAME):
|
|
1362
|
+
cleared = 0
|
|
1363
|
+
if clear_grants:
|
|
1364
|
+
grant_rows = await UserSecretGrant.filter(credential_item_id=item_id).values("id")
|
|
1365
|
+
cleared = len(grant_rows)
|
|
1366
|
+
await UserSecretGrant.delete_where(credential_item_id=item_id)
|
|
1017
1367
|
await CredentialItem.update_where(
|
|
1018
1368
|
{"id": item_id, "deleted_at__isnull": True},
|
|
1019
1369
|
user_id=to_user_id,
|
|
@@ -1028,18 +1378,27 @@ async def transfer_credential_item(
|
|
|
1028
1378
|
updated_by=actor_id,
|
|
1029
1379
|
updated_at=now,
|
|
1030
1380
|
)
|
|
1381
|
+
transfer_meta = {
|
|
1382
|
+
"credential_item_id": item_id,
|
|
1383
|
+
"from_user_id": from_user_id,
|
|
1384
|
+
"from_organization_id": from_organization_id,
|
|
1385
|
+
"to_user_id": to_user_id,
|
|
1386
|
+
"to_organization_id": to_organization_id,
|
|
1387
|
+
"grants_cleared": cleared,
|
|
1388
|
+
}
|
|
1031
1389
|
await record_secret_audit(
|
|
1032
1390
|
action="item_transferred",
|
|
1033
1391
|
actor_id=actor_id,
|
|
1034
1392
|
organization_id=to_organization_id,
|
|
1035
1393
|
user_id=to_user_id,
|
|
1036
|
-
metadata=
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1394
|
+
metadata=transfer_meta,
|
|
1395
|
+
)
|
|
1396
|
+
await record_secret_audit(
|
|
1397
|
+
action="item_transferred_out",
|
|
1398
|
+
actor_id=actor_id,
|
|
1399
|
+
organization_id=from_organization_id,
|
|
1400
|
+
user_id=from_user_id,
|
|
1401
|
+
metadata=transfer_meta,
|
|
1043
1402
|
)
|
|
1044
1403
|
item = await get_credential_item(item_id)
|
|
1045
1404
|
if item is None: # pragma: no cover
|
|
@@ -1077,6 +1436,11 @@ async def fork_credential_item(
|
|
|
1077
1436
|
source=raw["source"],
|
|
1078
1437
|
access_mode="all_members",
|
|
1079
1438
|
lifecycle={**dict(raw.get("lifecycle") or {}), "forked_from": item_id},
|
|
1439
|
+
login_urls=list(raw.get("login_urls") or []),
|
|
1440
|
+
uri_match_mode=str(raw.get("uri_match_mode") or "host"),
|
|
1441
|
+
notes=raw.get("notes"),
|
|
1442
|
+
non_secret_fields=list(raw.get("non_secret_fields") or []),
|
|
1443
|
+
browser_fill_enabled=bool(raw.get("browser_fill_enabled", False)),
|
|
1080
1444
|
created_by=actor_id,
|
|
1081
1445
|
updated_by=actor_id,
|
|
1082
1446
|
)
|
|
@@ -1148,6 +1512,7 @@ __all__ = [
|
|
|
1148
1512
|
"FieldNotEditableError",
|
|
1149
1513
|
"FieldSpec",
|
|
1150
1514
|
"ItemGrantSpec",
|
|
1515
|
+
"SandboxInjectionWithoutEnvKeyError",
|
|
1151
1516
|
"SealedFieldError",
|
|
1152
1517
|
"add_credential_field",
|
|
1153
1518
|
"create_credential_bundle",
|