omnibase_infra 0.2.1__py3-none-any.whl
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.
- omnibase_infra/__init__.py +101 -0
- omnibase_infra/cli/__init__.py +1 -0
- omnibase_infra/cli/commands.py +216 -0
- omnibase_infra/clients/__init__.py +0 -0
- omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +261 -0
- omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +138 -0
- omnibase_infra/decorators/__init__.py +29 -0
- omnibase_infra/decorators/allow_any.py +109 -0
- omnibase_infra/dlq/__init__.py +90 -0
- omnibase_infra/dlq/constants_dlq.py +57 -0
- omnibase_infra/dlq/models/__init__.py +26 -0
- omnibase_infra/dlq/models/enum_replay_status.py +37 -0
- omnibase_infra/dlq/models/model_dlq_replay_record.py +135 -0
- omnibase_infra/dlq/models/model_dlq_tracking_config.py +184 -0
- omnibase_infra/dlq/service_dlq_tracking.py +611 -0
- omnibase_infra/enums/__init__.py +123 -0
- omnibase_infra/enums/enum_any_type_violation.py +104 -0
- omnibase_infra/enums/enum_backend_type.py +27 -0
- omnibase_infra/enums/enum_capture_outcome.py +42 -0
- omnibase_infra/enums/enum_capture_state.py +88 -0
- omnibase_infra/enums/enum_chain_violation_type.py +119 -0
- omnibase_infra/enums/enum_circuit_state.py +51 -0
- omnibase_infra/enums/enum_confirmation_event_type.py +27 -0
- omnibase_infra/enums/enum_contract_type.py +84 -0
- omnibase_infra/enums/enum_dedupe_strategy.py +46 -0
- omnibase_infra/enums/enum_dispatch_status.py +191 -0
- omnibase_infra/enums/enum_environment.py +46 -0
- omnibase_infra/enums/enum_execution_shape_violation.py +103 -0
- omnibase_infra/enums/enum_handler_error_type.py +101 -0
- omnibase_infra/enums/enum_handler_loader_error.py +178 -0
- omnibase_infra/enums/enum_handler_source_type.py +87 -0
- omnibase_infra/enums/enum_handler_type.py +77 -0
- omnibase_infra/enums/enum_handler_type_category.py +61 -0
- omnibase_infra/enums/enum_infra_transport_type.py +73 -0
- omnibase_infra/enums/enum_introspection_reason.py +154 -0
- omnibase_infra/enums/enum_message_category.py +213 -0
- omnibase_infra/enums/enum_node_archetype.py +74 -0
- omnibase_infra/enums/enum_node_output_type.py +185 -0
- omnibase_infra/enums/enum_non_retryable_error_category.py +224 -0
- omnibase_infra/enums/enum_policy_type.py +32 -0
- omnibase_infra/enums/enum_registration_state.py +261 -0
- omnibase_infra/enums/enum_registration_status.py +33 -0
- omnibase_infra/enums/enum_registry_response_status.py +28 -0
- omnibase_infra/enums/enum_response_status.py +26 -0
- omnibase_infra/enums/enum_retry_error_category.py +98 -0
- omnibase_infra/enums/enum_security_rule_id.py +103 -0
- omnibase_infra/enums/enum_selection_strategy.py +91 -0
- omnibase_infra/enums/enum_topic_standard.py +42 -0
- omnibase_infra/enums/enum_validation_severity.py +78 -0
- omnibase_infra/errors/__init__.py +156 -0
- omnibase_infra/errors/error_architecture_violation.py +152 -0
- omnibase_infra/errors/error_chain_propagation.py +188 -0
- omnibase_infra/errors/error_compute_registry.py +92 -0
- omnibase_infra/errors/error_consul.py +132 -0
- omnibase_infra/errors/error_container_wiring.py +243 -0
- omnibase_infra/errors/error_event_bus_registry.py +102 -0
- omnibase_infra/errors/error_infra.py +608 -0
- omnibase_infra/errors/error_message_type_registry.py +101 -0
- omnibase_infra/errors/error_policy_registry.py +112 -0
- omnibase_infra/errors/error_vault.py +123 -0
- omnibase_infra/event_bus/__init__.py +72 -0
- omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +86 -0
- omnibase_infra/event_bus/event_bus_inmemory.py +743 -0
- omnibase_infra/event_bus/event_bus_kafka.py +1658 -0
- omnibase_infra/event_bus/mixin_kafka_broadcast.py +184 -0
- omnibase_infra/event_bus/mixin_kafka_dlq.py +765 -0
- omnibase_infra/event_bus/models/__init__.py +29 -0
- omnibase_infra/event_bus/models/config/__init__.py +20 -0
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +725 -0
- omnibase_infra/event_bus/models/model_dlq_event.py +206 -0
- omnibase_infra/event_bus/models/model_dlq_metrics.py +304 -0
- omnibase_infra/event_bus/models/model_event_headers.py +115 -0
- omnibase_infra/event_bus/models/model_event_message.py +60 -0
- omnibase_infra/event_bus/topic_constants.py +376 -0
- omnibase_infra/handlers/__init__.py +75 -0
- omnibase_infra/handlers/filesystem/__init__.py +48 -0
- omnibase_infra/handlers/filesystem/enum_file_system_operation.py +35 -0
- omnibase_infra/handlers/filesystem/model_file_system_request.py +298 -0
- omnibase_infra/handlers/filesystem/model_file_system_result.py +166 -0
- omnibase_infra/handlers/handler_consul.py +787 -0
- omnibase_infra/handlers/handler_db.py +1039 -0
- omnibase_infra/handlers/handler_filesystem.py +1478 -0
- omnibase_infra/handlers/handler_graph.py +1154 -0
- omnibase_infra/handlers/handler_http.py +920 -0
- omnibase_infra/handlers/handler_manifest_persistence.contract.yaml +184 -0
- omnibase_infra/handlers/handler_manifest_persistence.py +1539 -0
- omnibase_infra/handlers/handler_mcp.py +748 -0
- omnibase_infra/handlers/handler_qdrant.py +1076 -0
- omnibase_infra/handlers/handler_vault.py +422 -0
- omnibase_infra/handlers/mcp/__init__.py +19 -0
- omnibase_infra/handlers/mcp/adapter_onex_to_mcp.py +446 -0
- omnibase_infra/handlers/mcp/protocols.py +178 -0
- omnibase_infra/handlers/mcp/transport_streamable_http.py +352 -0
- omnibase_infra/handlers/mixins/__init__.py +42 -0
- omnibase_infra/handlers/mixins/mixin_consul_initialization.py +349 -0
- omnibase_infra/handlers/mixins/mixin_consul_kv.py +337 -0
- omnibase_infra/handlers/mixins/mixin_consul_service.py +277 -0
- omnibase_infra/handlers/mixins/mixin_vault_initialization.py +338 -0
- omnibase_infra/handlers/mixins/mixin_vault_retry.py +412 -0
- omnibase_infra/handlers/mixins/mixin_vault_secrets.py +450 -0
- omnibase_infra/handlers/mixins/mixin_vault_token.py +365 -0
- omnibase_infra/handlers/models/__init__.py +286 -0
- omnibase_infra/handlers/models/consul/__init__.py +81 -0
- omnibase_infra/handlers/models/consul/enum_consul_operation_type.py +57 -0
- omnibase_infra/handlers/models/consul/model_consul_deregister_payload.py +51 -0
- omnibase_infra/handlers/models/consul/model_consul_handler_config.py +153 -0
- omnibase_infra/handlers/models/consul/model_consul_handler_payload.py +89 -0
- omnibase_infra/handlers/models/consul/model_consul_kv_get_found_payload.py +55 -0
- omnibase_infra/handlers/models/consul/model_consul_kv_get_not_found_payload.py +49 -0
- omnibase_infra/handlers/models/consul/model_consul_kv_get_recurse_payload.py +50 -0
- omnibase_infra/handlers/models/consul/model_consul_kv_item.py +33 -0
- omnibase_infra/handlers/models/consul/model_consul_kv_put_payload.py +41 -0
- omnibase_infra/handlers/models/consul/model_consul_register_payload.py +53 -0
- omnibase_infra/handlers/models/consul/model_consul_retry_config.py +66 -0
- omnibase_infra/handlers/models/consul/model_payload_consul.py +66 -0
- omnibase_infra/handlers/models/consul/registry_payload_consul.py +214 -0
- omnibase_infra/handlers/models/graph/__init__.py +35 -0
- omnibase_infra/handlers/models/graph/enum_graph_operation_type.py +20 -0
- omnibase_infra/handlers/models/graph/model_graph_execute_payload.py +38 -0
- omnibase_infra/handlers/models/graph/model_graph_handler_config.py +54 -0
- omnibase_infra/handlers/models/graph/model_graph_handler_payload.py +44 -0
- omnibase_infra/handlers/models/graph/model_graph_query_payload.py +40 -0
- omnibase_infra/handlers/models/graph/model_graph_record.py +22 -0
- omnibase_infra/handlers/models/http/__init__.py +50 -0
- omnibase_infra/handlers/models/http/enum_http_operation_type.py +29 -0
- omnibase_infra/handlers/models/http/model_http_body_content.py +45 -0
- omnibase_infra/handlers/models/http/model_http_get_payload.py +88 -0
- omnibase_infra/handlers/models/http/model_http_handler_payload.py +90 -0
- omnibase_infra/handlers/models/http/model_http_post_payload.py +88 -0
- omnibase_infra/handlers/models/http/model_payload_http.py +66 -0
- omnibase_infra/handlers/models/http/registry_payload_http.py +212 -0
- omnibase_infra/handlers/models/mcp/__init__.py +23 -0
- omnibase_infra/handlers/models/mcp/enum_mcp_operation_type.py +24 -0
- omnibase_infra/handlers/models/mcp/model_mcp_handler_config.py +40 -0
- omnibase_infra/handlers/models/mcp/model_mcp_tool_call.py +32 -0
- omnibase_infra/handlers/models/mcp/model_mcp_tool_result.py +45 -0
- omnibase_infra/handlers/models/model_consul_handler_response.py +96 -0
- omnibase_infra/handlers/models/model_db_describe_response.py +83 -0
- omnibase_infra/handlers/models/model_db_query_payload.py +95 -0
- omnibase_infra/handlers/models/model_db_query_response.py +60 -0
- omnibase_infra/handlers/models/model_filesystem_config.py +98 -0
- omnibase_infra/handlers/models/model_filesystem_delete_payload.py +54 -0
- omnibase_infra/handlers/models/model_filesystem_delete_result.py +77 -0
- omnibase_infra/handlers/models/model_filesystem_directory_entry.py +75 -0
- omnibase_infra/handlers/models/model_filesystem_ensure_directory_payload.py +54 -0
- omnibase_infra/handlers/models/model_filesystem_ensure_directory_result.py +60 -0
- omnibase_infra/handlers/models/model_filesystem_list_directory_payload.py +60 -0
- omnibase_infra/handlers/models/model_filesystem_list_directory_result.py +68 -0
- omnibase_infra/handlers/models/model_filesystem_read_payload.py +62 -0
- omnibase_infra/handlers/models/model_filesystem_read_result.py +61 -0
- omnibase_infra/handlers/models/model_filesystem_write_payload.py +70 -0
- omnibase_infra/handlers/models/model_filesystem_write_result.py +55 -0
- omnibase_infra/handlers/models/model_graph_handler_response.py +98 -0
- omnibase_infra/handlers/models/model_handler_response.py +103 -0
- omnibase_infra/handlers/models/model_http_handler_response.py +101 -0
- omnibase_infra/handlers/models/model_manifest_metadata.py +75 -0
- omnibase_infra/handlers/models/model_manifest_persistence_config.py +62 -0
- omnibase_infra/handlers/models/model_manifest_query_payload.py +90 -0
- omnibase_infra/handlers/models/model_manifest_query_result.py +97 -0
- omnibase_infra/handlers/models/model_manifest_retrieve_payload.py +44 -0
- omnibase_infra/handlers/models/model_manifest_retrieve_result.py +98 -0
- omnibase_infra/handlers/models/model_manifest_store_payload.py +47 -0
- omnibase_infra/handlers/models/model_manifest_store_result.py +67 -0
- omnibase_infra/handlers/models/model_operation_context.py +187 -0
- omnibase_infra/handlers/models/model_qdrant_handler_response.py +98 -0
- omnibase_infra/handlers/models/model_retry_state.py +162 -0
- omnibase_infra/handlers/models/model_vault_handler_response.py +98 -0
- omnibase_infra/handlers/models/qdrant/__init__.py +44 -0
- omnibase_infra/handlers/models/qdrant/enum_qdrant_operation_type.py +26 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_collection_payload.py +42 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_delete_payload.py +36 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_handler_config.py +42 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_handler_payload.py +54 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_search_payload.py +42 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_search_result.py +30 -0
- omnibase_infra/handlers/models/qdrant/model_qdrant_upsert_payload.py +36 -0
- omnibase_infra/handlers/models/vault/__init__.py +69 -0
- omnibase_infra/handlers/models/vault/enum_vault_operation_type.py +35 -0
- omnibase_infra/handlers/models/vault/model_payload_vault.py +66 -0
- omnibase_infra/handlers/models/vault/model_vault_delete_payload.py +57 -0
- omnibase_infra/handlers/models/vault/model_vault_handler_config.py +148 -0
- omnibase_infra/handlers/models/vault/model_vault_handler_payload.py +101 -0
- omnibase_infra/handlers/models/vault/model_vault_list_payload.py +58 -0
- omnibase_infra/handlers/models/vault/model_vault_renew_token_payload.py +67 -0
- omnibase_infra/handlers/models/vault/model_vault_retry_config.py +66 -0
- omnibase_infra/handlers/models/vault/model_vault_secret_payload.py +106 -0
- omnibase_infra/handlers/models/vault/model_vault_write_payload.py +66 -0
- omnibase_infra/handlers/models/vault/registry_payload_vault.py +213 -0
- omnibase_infra/handlers/registration_storage/__init__.py +43 -0
- omnibase_infra/handlers/registration_storage/handler_registration_storage_mock.py +392 -0
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +915 -0
- omnibase_infra/handlers/registration_storage/models/__init__.py +23 -0
- omnibase_infra/handlers/registration_storage/models/model_delete_registration_request.py +58 -0
- omnibase_infra/handlers/registration_storage/models/model_update_registration_request.py +73 -0
- omnibase_infra/handlers/registration_storage/protocol_registration_persistence.py +191 -0
- omnibase_infra/handlers/service_discovery/__init__.py +43 -0
- omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +747 -0
- omnibase_infra/handlers/service_discovery/handler_service_discovery_mock.py +258 -0
- omnibase_infra/handlers/service_discovery/models/__init__.py +22 -0
- omnibase_infra/handlers/service_discovery/models/model_discovery_result.py +64 -0
- omnibase_infra/handlers/service_discovery/models/model_registration_result.py +138 -0
- omnibase_infra/handlers/service_discovery/models/model_service_info.py +99 -0
- omnibase_infra/handlers/service_discovery/protocol_discovery_operations.py +170 -0
- omnibase_infra/idempotency/__init__.py +94 -0
- omnibase_infra/idempotency/models/__init__.py +43 -0
- omnibase_infra/idempotency/models/model_idempotency_check_result.py +85 -0
- omnibase_infra/idempotency/models/model_idempotency_guard_config.py +130 -0
- omnibase_infra/idempotency/models/model_idempotency_record.py +86 -0
- omnibase_infra/idempotency/models/model_idempotency_store_health_check_result.py +81 -0
- omnibase_infra/idempotency/models/model_idempotency_store_metrics.py +140 -0
- omnibase_infra/idempotency/models/model_postgres_idempotency_store_config.py +299 -0
- omnibase_infra/idempotency/protocol_idempotency_store.py +184 -0
- omnibase_infra/idempotency/store_inmemory.py +265 -0
- omnibase_infra/idempotency/store_postgres.py +923 -0
- omnibase_infra/infrastructure/__init__.py +0 -0
- omnibase_infra/mixins/__init__.py +71 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +655 -0
- omnibase_infra/mixins/mixin_dict_like_accessors.py +146 -0
- omnibase_infra/mixins/mixin_envelope_extraction.py +119 -0
- omnibase_infra/mixins/mixin_node_introspection.py +2465 -0
- omnibase_infra/mixins/mixin_retry_execution.py +386 -0
- omnibase_infra/mixins/protocol_circuit_breaker_aware.py +133 -0
- omnibase_infra/models/__init__.py +136 -0
- omnibase_infra/models/corpus/__init__.py +17 -0
- omnibase_infra/models/corpus/model_capture_config.py +133 -0
- omnibase_infra/models/corpus/model_capture_result.py +86 -0
- omnibase_infra/models/discovery/__init__.py +42 -0
- omnibase_infra/models/discovery/model_dependency_spec.py +319 -0
- omnibase_infra/models/discovery/model_discovered_capabilities.py +50 -0
- omnibase_infra/models/discovery/model_introspection_config.py +311 -0
- omnibase_infra/models/discovery/model_introspection_performance_metrics.py +169 -0
- omnibase_infra/models/discovery/model_introspection_task_config.py +116 -0
- omnibase_infra/models/dispatch/__init__.py +147 -0
- omnibase_infra/models/dispatch/model_dispatch_context.py +439 -0
- omnibase_infra/models/dispatch/model_dispatch_error.py +336 -0
- omnibase_infra/models/dispatch/model_dispatch_log_context.py +400 -0
- omnibase_infra/models/dispatch/model_dispatch_metadata.py +228 -0
- omnibase_infra/models/dispatch/model_dispatch_metrics.py +496 -0
- omnibase_infra/models/dispatch/model_dispatch_outcome.py +317 -0
- omnibase_infra/models/dispatch/model_dispatch_outputs.py +231 -0
- omnibase_infra/models/dispatch/model_dispatch_result.py +436 -0
- omnibase_infra/models/dispatch/model_dispatch_route.py +279 -0
- omnibase_infra/models/dispatch/model_dispatcher_metrics.py +275 -0
- omnibase_infra/models/dispatch/model_dispatcher_registration.py +352 -0
- omnibase_infra/models/dispatch/model_parsed_topic.py +135 -0
- omnibase_infra/models/dispatch/model_topic_parser.py +725 -0
- omnibase_infra/models/dispatch/model_tracing_context.py +285 -0
- omnibase_infra/models/errors/__init__.py +45 -0
- omnibase_infra/models/errors/model_handler_validation_error.py +594 -0
- omnibase_infra/models/errors/model_infra_error_context.py +99 -0
- omnibase_infra/models/errors/model_message_type_registry_error_context.py +71 -0
- omnibase_infra/models/errors/model_timeout_error_context.py +110 -0
- omnibase_infra/models/handlers/__init__.py +37 -0
- omnibase_infra/models/handlers/model_contract_discovery_result.py +80 -0
- omnibase_infra/models/handlers/model_handler_descriptor.py +185 -0
- omnibase_infra/models/handlers/model_handler_identifier.py +215 -0
- omnibase_infra/models/health/__init__.py +9 -0
- omnibase_infra/models/health/model_health_check_result.py +40 -0
- omnibase_infra/models/lifecycle/__init__.py +39 -0
- omnibase_infra/models/logging/__init__.py +51 -0
- omnibase_infra/models/logging/model_log_context.py +756 -0
- omnibase_infra/models/model_retry_error_classification.py +78 -0
- omnibase_infra/models/projection/__init__.py +43 -0
- omnibase_infra/models/projection/model_capability_fields.py +112 -0
- omnibase_infra/models/projection/model_registration_projection.py +434 -0
- omnibase_infra/models/projection/model_registration_snapshot.py +322 -0
- omnibase_infra/models/projection/model_sequence_info.py +182 -0
- omnibase_infra/models/projection/model_snapshot_topic_config.py +590 -0
- omnibase_infra/models/projectors/__init__.py +41 -0
- omnibase_infra/models/projectors/model_projector_column.py +289 -0
- omnibase_infra/models/projectors/model_projector_discovery_result.py +65 -0
- omnibase_infra/models/projectors/model_projector_index.py +270 -0
- omnibase_infra/models/projectors/model_projector_schema.py +415 -0
- omnibase_infra/models/projectors/model_projector_validation_error.py +63 -0
- omnibase_infra/models/projectors/util_sql_identifiers.py +115 -0
- omnibase_infra/models/registration/__init__.py +59 -0
- omnibase_infra/models/registration/commands/__init__.py +15 -0
- omnibase_infra/models/registration/commands/model_node_registration_acked.py +108 -0
- omnibase_infra/models/registration/events/__init__.py +56 -0
- omnibase_infra/models/registration/events/model_node_became_active.py +103 -0
- omnibase_infra/models/registration/events/model_node_liveness_expired.py +103 -0
- omnibase_infra/models/registration/events/model_node_registration_accepted.py +98 -0
- omnibase_infra/models/registration/events/model_node_registration_ack_received.py +98 -0
- omnibase_infra/models/registration/events/model_node_registration_ack_timed_out.py +112 -0
- omnibase_infra/models/registration/events/model_node_registration_initiated.py +107 -0
- omnibase_infra/models/registration/events/model_node_registration_rejected.py +104 -0
- omnibase_infra/models/registration/model_introspection_metrics.py +253 -0
- omnibase_infra/models/registration/model_node_capabilities.py +179 -0
- omnibase_infra/models/registration/model_node_heartbeat_event.py +126 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +175 -0
- omnibase_infra/models/registration/model_node_metadata.py +79 -0
- omnibase_infra/models/registration/model_node_registration.py +162 -0
- omnibase_infra/models/registration/model_node_registration_record.py +162 -0
- omnibase_infra/models/registry/__init__.py +29 -0
- omnibase_infra/models/registry/model_domain_constraint.py +202 -0
- omnibase_infra/models/registry/model_message_type_entry.py +271 -0
- omnibase_infra/models/resilience/__init__.py +9 -0
- omnibase_infra/models/resilience/model_circuit_breaker_config.py +227 -0
- omnibase_infra/models/routing/__init__.py +25 -0
- omnibase_infra/models/routing/model_routing_entry.py +52 -0
- omnibase_infra/models/routing/model_routing_subcontract.py +70 -0
- omnibase_infra/models/runtime/__init__.py +40 -0
- omnibase_infra/models/runtime/model_contract_security_config.py +41 -0
- omnibase_infra/models/runtime/model_discovery_error.py +81 -0
- omnibase_infra/models/runtime/model_discovery_result.py +162 -0
- omnibase_infra/models/runtime/model_discovery_warning.py +74 -0
- omnibase_infra/models/runtime/model_failed_plugin_load.py +63 -0
- omnibase_infra/models/runtime/model_handler_contract.py +280 -0
- omnibase_infra/models/runtime/model_loaded_handler.py +120 -0
- omnibase_infra/models/runtime/model_plugin_load_context.py +93 -0
- omnibase_infra/models/runtime/model_plugin_load_summary.py +124 -0
- omnibase_infra/models/security/__init__.py +50 -0
- omnibase_infra/models/security/classification_levels.py +99 -0
- omnibase_infra/models/security/model_environment_policy.py +145 -0
- omnibase_infra/models/security/model_handler_security_policy.py +107 -0
- omnibase_infra/models/security/model_security_error.py +81 -0
- omnibase_infra/models/security/model_security_validation_result.py +328 -0
- omnibase_infra/models/security/model_security_warning.py +67 -0
- omnibase_infra/models/snapshot/__init__.py +27 -0
- omnibase_infra/models/snapshot/model_field_change.py +65 -0
- omnibase_infra/models/snapshot/model_snapshot.py +270 -0
- omnibase_infra/models/snapshot/model_snapshot_diff.py +203 -0
- omnibase_infra/models/snapshot/model_subject_ref.py +81 -0
- omnibase_infra/models/types/__init__.py +71 -0
- omnibase_infra/models/validation/__init__.py +89 -0
- omnibase_infra/models/validation/model_any_type_validation_result.py +118 -0
- omnibase_infra/models/validation/model_any_type_violation.py +141 -0
- omnibase_infra/models/validation/model_category_match_result.py +345 -0
- omnibase_infra/models/validation/model_chain_violation.py +166 -0
- omnibase_infra/models/validation/model_coverage_metrics.py +316 -0
- omnibase_infra/models/validation/model_execution_shape_rule.py +159 -0
- omnibase_infra/models/validation/model_execution_shape_validation.py +208 -0
- omnibase_infra/models/validation/model_execution_shape_validation_result.py +294 -0
- omnibase_infra/models/validation/model_execution_shape_violation.py +122 -0
- omnibase_infra/models/validation/model_localhandler_validation_result.py +139 -0
- omnibase_infra/models/validation/model_localhandler_violation.py +100 -0
- omnibase_infra/models/validation/model_output_validation_params.py +74 -0
- omnibase_infra/models/validation/model_validate_and_raise_params.py +84 -0
- omnibase_infra/models/validation/model_validation_error_params.py +84 -0
- omnibase_infra/models/validation/model_validation_outcome.py +287 -0
- omnibase_infra/nodes/__init__.py +48 -0
- omnibase_infra/nodes/architecture_validator/__init__.py +79 -0
- omnibase_infra/nodes/architecture_validator/contract.yaml +252 -0
- omnibase_infra/nodes/architecture_validator/contract_architecture_validator.yaml +208 -0
- omnibase_infra/nodes/architecture_validator/mixins/__init__.py +16 -0
- omnibase_infra/nodes/architecture_validator/mixins/mixin_file_path_rule.py +92 -0
- omnibase_infra/nodes/architecture_validator/models/__init__.py +36 -0
- omnibase_infra/nodes/architecture_validator/models/model_architecture_validation_request.py +56 -0
- omnibase_infra/nodes/architecture_validator/models/model_architecture_validation_result.py +311 -0
- omnibase_infra/nodes/architecture_validator/models/model_architecture_violation.py +163 -0
- omnibase_infra/nodes/architecture_validator/models/model_rule_check_result.py +265 -0
- omnibase_infra/nodes/architecture_validator/models/model_validation_request.py +105 -0
- omnibase_infra/nodes/architecture_validator/models/model_validation_result.py +314 -0
- omnibase_infra/nodes/architecture_validator/node.py +262 -0
- omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +383 -0
- omnibase_infra/nodes/architecture_validator/protocols/__init__.py +9 -0
- omnibase_infra/nodes/architecture_validator/protocols/protocol_architecture_rule.py +225 -0
- omnibase_infra/nodes/architecture_validator/registry/__init__.py +28 -0
- omnibase_infra/nodes/architecture_validator/registry/registry_infra_architecture_validator.py +99 -0
- omnibase_infra/nodes/architecture_validator/validators/__init__.py +104 -0
- omnibase_infra/nodes/architecture_validator/validators/validator_no_direct_dispatch.py +422 -0
- omnibase_infra/nodes/architecture_validator/validators/validator_no_handler_publishing.py +481 -0
- omnibase_infra/nodes/architecture_validator/validators/validator_no_orchestrator_fsm.py +491 -0
- omnibase_infra/nodes/effects/README.md +358 -0
- omnibase_infra/nodes/effects/__init__.py +26 -0
- omnibase_infra/nodes/effects/contract.yaml +172 -0
- omnibase_infra/nodes/effects/models/__init__.py +32 -0
- omnibase_infra/nodes/effects/models/model_backend_result.py +190 -0
- omnibase_infra/nodes/effects/models/model_effect_idempotency_config.py +92 -0
- omnibase_infra/nodes/effects/models/model_registry_request.py +132 -0
- omnibase_infra/nodes/effects/models/model_registry_response.py +263 -0
- omnibase_infra/nodes/effects/protocol_consul_client.py +89 -0
- omnibase_infra/nodes/effects/protocol_effect_idempotency_store.py +143 -0
- omnibase_infra/nodes/effects/protocol_postgres_adapter.py +96 -0
- omnibase_infra/nodes/effects/registry_effect.py +525 -0
- omnibase_infra/nodes/effects/store_effect_idempotency_inmemory.py +425 -0
- omnibase_infra/nodes/node_registration_orchestrator/README.md +542 -0
- omnibase_infra/nodes/node_registration_orchestrator/__init__.py +120 -0
- omnibase_infra/nodes/node_registration_orchestrator/contract.yaml +475 -0
- omnibase_infra/nodes/node_registration_orchestrator/dispatchers/__init__.py +53 -0
- omnibase_infra/nodes/node_registration_orchestrator/dispatchers/dispatcher_node_introspected.py +376 -0
- omnibase_infra/nodes/node_registration_orchestrator/dispatchers/dispatcher_node_registration_acked.py +376 -0
- omnibase_infra/nodes/node_registration_orchestrator/dispatchers/dispatcher_runtime_tick.py +373 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/__init__.py +62 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_heartbeat.py +376 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_introspected.py +609 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_registration_acked.py +458 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_runtime_tick.py +364 -0
- omnibase_infra/nodes/node_registration_orchestrator/introspection_event_router.py +544 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/__init__.py +75 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_consul_intent_payload.py +194 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_consul_registration_intent.py +67 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_intent_execution_result.py +50 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_node_liveness_expired.py +107 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_orchestrator_config.py +67 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_orchestrator_input.py +41 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_orchestrator_output.py +166 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_intent_payload.py +235 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_upsert_intent.py +68 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_reducer_execution_result.py +384 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_reducer_state.py +60 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_registration_intent.py +177 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_registry_intent.py +247 -0
- omnibase_infra/nodes/node_registration_orchestrator/node.py +195 -0
- omnibase_infra/nodes/node_registration_orchestrator/plugin.py +909 -0
- omnibase_infra/nodes/node_registration_orchestrator/protocols.py +439 -0
- omnibase_infra/nodes/node_registration_orchestrator/registry/__init__.py +41 -0
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +525 -0
- omnibase_infra/nodes/node_registration_orchestrator/timeout_coordinator.py +392 -0
- omnibase_infra/nodes/node_registration_orchestrator/wiring.py +742 -0
- omnibase_infra/nodes/node_registration_reducer/__init__.py +15 -0
- omnibase_infra/nodes/node_registration_reducer/contract.yaml +301 -0
- omnibase_infra/nodes/node_registration_reducer/models/__init__.py +38 -0
- omnibase_infra/nodes/node_registration_reducer/models/model_validation_result.py +113 -0
- omnibase_infra/nodes/node_registration_reducer/node.py +139 -0
- omnibase_infra/nodes/node_registration_reducer/registry/__init__.py +9 -0
- omnibase_infra/nodes/node_registration_reducer/registry/registry_infra_node_registration_reducer.py +79 -0
- omnibase_infra/nodes/node_registration_storage_effect/__init__.py +41 -0
- omnibase_infra/nodes/node_registration_storage_effect/contract.yaml +225 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/__init__.py +44 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_delete_result.py +132 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_registration_record.py +199 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_registration_update.py +155 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_storage_health_check_details.py +123 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_storage_health_check_result.py +117 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_storage_query.py +100 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_storage_result.py +136 -0
- omnibase_infra/nodes/node_registration_storage_effect/models/model_upsert_result.py +127 -0
- omnibase_infra/nodes/node_registration_storage_effect/node.py +109 -0
- omnibase_infra/nodes/node_registration_storage_effect/protocols/__init__.py +22 -0
- omnibase_infra/nodes/node_registration_storage_effect/protocols/protocol_registration_persistence.py +333 -0
- omnibase_infra/nodes/node_registration_storage_effect/registry/__init__.py +23 -0
- omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +194 -0
- omnibase_infra/nodes/node_registry_effect/__init__.py +85 -0
- omnibase_infra/nodes/node_registry_effect/contract.yaml +682 -0
- omnibase_infra/nodes/node_registry_effect/handlers/__init__.py +70 -0
- omnibase_infra/nodes/node_registry_effect/handlers/handler_consul_deregister.py +211 -0
- omnibase_infra/nodes/node_registry_effect/handlers/handler_consul_register.py +212 -0
- omnibase_infra/nodes/node_registry_effect/handlers/handler_partial_retry.py +416 -0
- omnibase_infra/nodes/node_registry_effect/handlers/handler_postgres_deactivate.py +215 -0
- omnibase_infra/nodes/node_registry_effect/handlers/handler_postgres_upsert.py +208 -0
- omnibase_infra/nodes/node_registry_effect/models/__init__.py +43 -0
- omnibase_infra/nodes/node_registry_effect/models/model_partial_retry_request.py +92 -0
- omnibase_infra/nodes/node_registry_effect/node.py +165 -0
- omnibase_infra/nodes/node_registry_effect/registry/__init__.py +27 -0
- omnibase_infra/nodes/node_registry_effect/registry/registry_infra_registry_effect.py +196 -0
- omnibase_infra/nodes/node_service_discovery_effect/__init__.py +111 -0
- omnibase_infra/nodes/node_service_discovery_effect/contract.yaml +246 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/__init__.py +67 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/enum_health_status.py +72 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/enum_service_discovery_operation.py +58 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_discovery_query.py +99 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_discovery_result.py +98 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_health_check_config.py +121 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_query_metadata.py +63 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_registration_result.py +130 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_service_discovery_health_check_details.py +111 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_service_discovery_health_check_result.py +119 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_service_info.py +106 -0
- omnibase_infra/nodes/node_service_discovery_effect/models/model_service_registration.py +121 -0
- omnibase_infra/nodes/node_service_discovery_effect/node.py +111 -0
- omnibase_infra/nodes/node_service_discovery_effect/protocols/__init__.py +14 -0
- omnibase_infra/nodes/node_service_discovery_effect/protocols/protocol_discovery_operations.py +279 -0
- omnibase_infra/nodes/node_service_discovery_effect/registry/__init__.py +13 -0
- omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +214 -0
- omnibase_infra/nodes/reducers/__init__.py +30 -0
- omnibase_infra/nodes/reducers/models/__init__.py +32 -0
- omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +76 -0
- omnibase_infra/nodes/reducers/models/model_payload_postgres_upsert_registration.py +60 -0
- omnibase_infra/nodes/reducers/models/model_registration_confirmation.py +166 -0
- omnibase_infra/nodes/reducers/models/model_registration_state.py +433 -0
- omnibase_infra/nodes/reducers/registration_reducer.py +1137 -0
- omnibase_infra/observability/__init__.py +143 -0
- omnibase_infra/observability/constants_metrics.py +91 -0
- omnibase_infra/observability/factory_observability_sink.py +525 -0
- omnibase_infra/observability/handlers/__init__.py +118 -0
- omnibase_infra/observability/handlers/handler_logging_structured.py +967 -0
- omnibase_infra/observability/handlers/handler_metrics_prometheus.py +1120 -0
- omnibase_infra/observability/handlers/model_logging_handler_config.py +71 -0
- omnibase_infra/observability/handlers/model_logging_handler_response.py +77 -0
- omnibase_infra/observability/handlers/model_metrics_handler_config.py +172 -0
- omnibase_infra/observability/handlers/model_metrics_handler_payload.py +135 -0
- omnibase_infra/observability/handlers/model_metrics_handler_response.py +101 -0
- omnibase_infra/observability/hooks/__init__.py +74 -0
- omnibase_infra/observability/hooks/hook_observability.py +1223 -0
- omnibase_infra/observability/models/__init__.py +30 -0
- omnibase_infra/observability/models/enum_required_log_context_key.py +77 -0
- omnibase_infra/observability/models/model_buffered_log_entry.py +117 -0
- omnibase_infra/observability/models/model_logging_sink_config.py +73 -0
- omnibase_infra/observability/models/model_metrics_sink_config.py +156 -0
- omnibase_infra/observability/sinks/__init__.py +69 -0
- omnibase_infra/observability/sinks/sink_logging_structured.py +809 -0
- omnibase_infra/observability/sinks/sink_metrics_prometheus.py +710 -0
- omnibase_infra/plugins/__init__.py +27 -0
- omnibase_infra/plugins/examples/__init__.py +28 -0
- omnibase_infra/plugins/examples/plugin_json_normalizer.py +271 -0
- omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +210 -0
- omnibase_infra/plugins/models/__init__.py +21 -0
- omnibase_infra/plugins/models/model_plugin_context.py +76 -0
- omnibase_infra/plugins/models/model_plugin_input_data.py +58 -0
- omnibase_infra/plugins/models/model_plugin_output_data.py +62 -0
- omnibase_infra/plugins/plugin_compute_base.py +435 -0
- omnibase_infra/projectors/__init__.py +30 -0
- omnibase_infra/projectors/contracts/__init__.py +63 -0
- omnibase_infra/projectors/contracts/registration_projector.yaml +370 -0
- omnibase_infra/projectors/projection_reader_registration.py +1559 -0
- omnibase_infra/projectors/snapshot_publisher_registration.py +1329 -0
- omnibase_infra/protocols/__init__.py +99 -0
- omnibase_infra/protocols/protocol_capability_projection.py +253 -0
- omnibase_infra/protocols/protocol_capability_query.py +251 -0
- omnibase_infra/protocols/protocol_event_bus_like.py +127 -0
- omnibase_infra/protocols/protocol_event_projector.py +96 -0
- omnibase_infra/protocols/protocol_idempotency_store.py +142 -0
- omnibase_infra/protocols/protocol_message_dispatcher.py +247 -0
- omnibase_infra/protocols/protocol_message_type_registry.py +306 -0
- omnibase_infra/protocols/protocol_plugin_compute.py +368 -0
- omnibase_infra/protocols/protocol_projector_schema_validator.py +82 -0
- omnibase_infra/protocols/protocol_registry_metrics.py +215 -0
- omnibase_infra/protocols/protocol_snapshot_publisher.py +396 -0
- omnibase_infra/protocols/protocol_snapshot_store.py +567 -0
- omnibase_infra/runtime/__init__.py +296 -0
- omnibase_infra/runtime/binding_config_resolver.py +2706 -0
- omnibase_infra/runtime/chain_aware_dispatch.py +467 -0
- omnibase_infra/runtime/contract_handler_discovery.py +582 -0
- omnibase_infra/runtime/contract_loaders/__init__.py +42 -0
- omnibase_infra/runtime/contract_loaders/handler_routing_loader.py +464 -0
- omnibase_infra/runtime/dispatch_context_enforcer.py +427 -0
- omnibase_infra/runtime/enums/__init__.py +18 -0
- omnibase_infra/runtime/enums/enum_config_ref_scheme.py +33 -0
- omnibase_infra/runtime/enums/enum_scheduler_status.py +170 -0
- omnibase_infra/runtime/envelope_validator.py +179 -0
- omnibase_infra/runtime/handler_contract_source.py +669 -0
- omnibase_infra/runtime/handler_plugin_loader.py +2029 -0
- omnibase_infra/runtime/handler_registry.py +321 -0
- omnibase_infra/runtime/invocation_security_enforcer.py +427 -0
- omnibase_infra/runtime/kernel.py +40 -0
- omnibase_infra/runtime/mixin_policy_validation.py +522 -0
- omnibase_infra/runtime/mixin_semver_cache.py +378 -0
- omnibase_infra/runtime/mixins/__init__.py +17 -0
- omnibase_infra/runtime/mixins/mixin_projector_sql_operations.py +757 -0
- omnibase_infra/runtime/models/__init__.py +192 -0
- omnibase_infra/runtime/models/model_batch_lifecycle_result.py +217 -0
- omnibase_infra/runtime/models/model_binding_config.py +168 -0
- omnibase_infra/runtime/models/model_binding_config_cache_stats.py +135 -0
- omnibase_infra/runtime/models/model_binding_config_resolver_config.py +329 -0
- omnibase_infra/runtime/models/model_cached_secret.py +138 -0
- omnibase_infra/runtime/models/model_compute_key.py +138 -0
- omnibase_infra/runtime/models/model_compute_registration.py +97 -0
- omnibase_infra/runtime/models/model_config_cache_entry.py +61 -0
- omnibase_infra/runtime/models/model_config_ref.py +331 -0
- omnibase_infra/runtime/models/model_config_ref_parse_result.py +125 -0
- omnibase_infra/runtime/models/model_domain_plugin_config.py +92 -0
- omnibase_infra/runtime/models/model_domain_plugin_result.py +270 -0
- omnibase_infra/runtime/models/model_duplicate_response.py +54 -0
- omnibase_infra/runtime/models/model_enabled_protocols_config.py +61 -0
- omnibase_infra/runtime/models/model_event_bus_config.py +54 -0
- omnibase_infra/runtime/models/model_failed_component.py +55 -0
- omnibase_infra/runtime/models/model_health_check_response.py +168 -0
- omnibase_infra/runtime/models/model_health_check_result.py +228 -0
- omnibase_infra/runtime/models/model_lifecycle_result.py +245 -0
- omnibase_infra/runtime/models/model_logging_config.py +42 -0
- omnibase_infra/runtime/models/model_optional_correlation_id.py +167 -0
- omnibase_infra/runtime/models/model_optional_string.py +94 -0
- omnibase_infra/runtime/models/model_optional_uuid.py +110 -0
- omnibase_infra/runtime/models/model_policy_context.py +100 -0
- omnibase_infra/runtime/models/model_policy_key.py +138 -0
- omnibase_infra/runtime/models/model_policy_registration.py +139 -0
- omnibase_infra/runtime/models/model_policy_result.py +103 -0
- omnibase_infra/runtime/models/model_policy_type_filter.py +157 -0
- omnibase_infra/runtime/models/model_projector_plugin_loader_config.py +47 -0
- omnibase_infra/runtime/models/model_protocol_registration_config.py +65 -0
- omnibase_infra/runtime/models/model_retry_policy.py +105 -0
- omnibase_infra/runtime/models/model_runtime_config.py +150 -0
- omnibase_infra/runtime/models/model_runtime_scheduler_config.py +624 -0
- omnibase_infra/runtime/models/model_runtime_scheduler_metrics.py +233 -0
- omnibase_infra/runtime/models/model_runtime_tick.py +193 -0
- omnibase_infra/runtime/models/model_secret_cache_stats.py +82 -0
- omnibase_infra/runtime/models/model_secret_mapping.py +63 -0
- omnibase_infra/runtime/models/model_secret_resolver_config.py +107 -0
- omnibase_infra/runtime/models/model_secret_resolver_metrics.py +111 -0
- omnibase_infra/runtime/models/model_secret_source_info.py +72 -0
- omnibase_infra/runtime/models/model_secret_source_spec.py +66 -0
- omnibase_infra/runtime/models/model_shutdown_batch_result.py +75 -0
- omnibase_infra/runtime/models/model_shutdown_config.py +94 -0
- omnibase_infra/runtime/projector_plugin_loader.py +1462 -0
- omnibase_infra/runtime/projector_schema_manager.py +565 -0
- omnibase_infra/runtime/projector_shell.py +1102 -0
- omnibase_infra/runtime/protocol_contract_descriptor.py +92 -0
- omnibase_infra/runtime/protocol_contract_source.py +92 -0
- omnibase_infra/runtime/protocol_domain_plugin.py +474 -0
- omnibase_infra/runtime/protocol_handler_discovery.py +221 -0
- omnibase_infra/runtime/protocol_handler_plugin_loader.py +327 -0
- omnibase_infra/runtime/protocol_lifecycle_executor.py +435 -0
- omnibase_infra/runtime/protocol_policy.py +366 -0
- omnibase_infra/runtime/protocols/__init__.py +27 -0
- omnibase_infra/runtime/protocols/protocol_runtime_scheduler.py +468 -0
- omnibase_infra/runtime/registry/__init__.py +93 -0
- omnibase_infra/runtime/registry/mixin_message_type_query.py +326 -0
- omnibase_infra/runtime/registry/mixin_message_type_registration.py +354 -0
- omnibase_infra/runtime/registry/registry_event_bus_binding.py +268 -0
- omnibase_infra/runtime/registry/registry_message_type.py +542 -0
- omnibase_infra/runtime/registry/registry_protocol_binding.py +444 -0
- omnibase_infra/runtime/registry_compute.py +1143 -0
- omnibase_infra/runtime/registry_dispatcher.py +678 -0
- omnibase_infra/runtime/registry_policy.py +1502 -0
- omnibase_infra/runtime/runtime_scheduler.py +1070 -0
- omnibase_infra/runtime/secret_resolver.py +2110 -0
- omnibase_infra/runtime/security_metadata_validator.py +776 -0
- omnibase_infra/runtime/service_kernel.py +1573 -0
- omnibase_infra/runtime/service_message_dispatch_engine.py +1805 -0
- omnibase_infra/runtime/service_runtime_host_process.py +2260 -0
- omnibase_infra/runtime/util_container_wiring.py +1123 -0
- omnibase_infra/runtime/util_validation.py +314 -0
- omnibase_infra/runtime/util_version.py +98 -0
- omnibase_infra/runtime/util_wiring.py +566 -0
- omnibase_infra/schemas/schema_registration_projection.sql +320 -0
- omnibase_infra/services/__init__.py +68 -0
- omnibase_infra/services/corpus_capture.py +678 -0
- omnibase_infra/services/service_capability_query.py +945 -0
- omnibase_infra/services/service_health.py +897 -0
- omnibase_infra/services/service_node_selector.py +530 -0
- omnibase_infra/services/service_timeout_emitter.py +682 -0
- omnibase_infra/services/service_timeout_scanner.py +390 -0
- omnibase_infra/services/snapshot/__init__.py +31 -0
- omnibase_infra/services/snapshot/service_snapshot.py +647 -0
- omnibase_infra/services/snapshot/store_inmemory.py +637 -0
- omnibase_infra/services/snapshot/store_postgres.py +1279 -0
- omnibase_infra/shared/__init__.py +8 -0
- omnibase_infra/testing/__init__.py +10 -0
- omnibase_infra/testing/utils.py +23 -0
- omnibase_infra/types/__init__.py +48 -0
- omnibase_infra/types/type_cache_info.py +49 -0
- omnibase_infra/types/type_dsn.py +173 -0
- omnibase_infra/types/type_infra_aliases.py +60 -0
- omnibase_infra/types/typed_dict/__init__.py +21 -0
- omnibase_infra/types/typed_dict/typed_dict_introspection_cache.py +128 -0
- omnibase_infra/types/typed_dict/typed_dict_performance_metrics_cache.py +140 -0
- omnibase_infra/types/typed_dict_capabilities.py +64 -0
- omnibase_infra/utils/__init__.py +89 -0
- omnibase_infra/utils/correlation.py +208 -0
- omnibase_infra/utils/util_datetime.py +372 -0
- omnibase_infra/utils/util_dsn_validation.py +333 -0
- omnibase_infra/utils/util_env_parsing.py +264 -0
- omnibase_infra/utils/util_error_sanitization.py +457 -0
- omnibase_infra/utils/util_pydantic_validators.py +477 -0
- omnibase_infra/utils/util_semver.py +233 -0
- omnibase_infra/validation/__init__.py +307 -0
- omnibase_infra/validation/enums/__init__.py +11 -0
- omnibase_infra/validation/enums/enum_contract_violation_severity.py +13 -0
- omnibase_infra/validation/infra_validators.py +1486 -0
- omnibase_infra/validation/linter_contract.py +907 -0
- omnibase_infra/validation/mixin_any_type_classification.py +120 -0
- omnibase_infra/validation/mixin_any_type_exemption.py +580 -0
- omnibase_infra/validation/mixin_any_type_reporting.py +106 -0
- omnibase_infra/validation/mixin_execution_shape_violation_checks.py +596 -0
- omnibase_infra/validation/mixin_node_archetype_detection.py +254 -0
- omnibase_infra/validation/models/__init__.py +15 -0
- omnibase_infra/validation/models/model_contract_lint_result.py +101 -0
- omnibase_infra/validation/models/model_contract_violation.py +41 -0
- omnibase_infra/validation/service_validation_aggregator.py +395 -0
- omnibase_infra/validation/validation_exemptions.yaml +1710 -0
- omnibase_infra/validation/validator_any_type.py +715 -0
- omnibase_infra/validation/validator_chain_propagation.py +839 -0
- omnibase_infra/validation/validator_execution_shape.py +465 -0
- omnibase_infra/validation/validator_localhandler.py +261 -0
- omnibase_infra/validation/validator_registration_security.py +410 -0
- omnibase_infra/validation/validator_routing_coverage.py +1020 -0
- omnibase_infra/validation/validator_runtime_shape.py +915 -0
- omnibase_infra/validation/validator_security.py +410 -0
- omnibase_infra/validation/validator_topic_category.py +1152 -0
- omnibase_infra-0.2.1.dist-info/METADATA +197 -0
- omnibase_infra-0.2.1.dist-info/RECORD +675 -0
- omnibase_infra-0.2.1.dist-info/WHEEL +4 -0
- omnibase_infra-0.2.1.dist-info/entry_points.txt +4 -0
- omnibase_infra-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2110 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Centralized secret resolution for ONEX infrastructure.
|
|
4
|
+
|
|
5
|
+
SecretResolver provides a unified interface for accessing secrets from multiple sources:
|
|
6
|
+
- Vault (via HandlerVault for KV v2 secrets engine)
|
|
7
|
+
- Environment variables
|
|
8
|
+
- File-based secrets (K8s /run/secrets)
|
|
9
|
+
|
|
10
|
+
Design Philosophy:
|
|
11
|
+
- Dumb and deterministic: resolves and caches, does not discover or mutate
|
|
12
|
+
- Explicit mappings preferred, convention fallback optional
|
|
13
|
+
- Bootstrap secrets (Vault token/addr) always from env
|
|
14
|
+
- Vault is treated as an injected dependency, SecretResolver owns mapping + caching + policy
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
Bootstrap phase (env-only for Vault credentials)::
|
|
18
|
+
|
|
19
|
+
vault_token = os.environ.get("VAULT_TOKEN")
|
|
20
|
+
vault_addr = os.environ.get("VAULT_ADDR")
|
|
21
|
+
|
|
22
|
+
Initialize resolver with Vault handler::
|
|
23
|
+
|
|
24
|
+
vault_handler = HandlerVault()
|
|
25
|
+
await vault_handler.initialize({...})
|
|
26
|
+
|
|
27
|
+
config = ModelSecretResolverConfig(mappings=[...])
|
|
28
|
+
resolver = SecretResolver(config=config, vault_handler=vault_handler)
|
|
29
|
+
|
|
30
|
+
Resolve secrets with correlation ID for tracing::
|
|
31
|
+
|
|
32
|
+
db_password = resolver.get_secret(
|
|
33
|
+
"database.postgres.password",
|
|
34
|
+
correlation_id=request.correlation_id,
|
|
35
|
+
)
|
|
36
|
+
api_key = await resolver.get_secret_async(
|
|
37
|
+
"llm.openai.api_key",
|
|
38
|
+
required=False,
|
|
39
|
+
correlation_id=request.correlation_id,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
Get resolution metrics::
|
|
43
|
+
|
|
44
|
+
metrics = resolver.get_resolution_metrics()
|
|
45
|
+
# ModelSecretResolverMetrics(success_counts={"env": 5, "vault": 3}, ...)
|
|
46
|
+
|
|
47
|
+
Security Considerations:
|
|
48
|
+
- Secret values are wrapped in SecretStr to prevent accidental logging
|
|
49
|
+
- Cache stores SecretStr values, never raw strings
|
|
50
|
+
- Introspection methods never expose secret values
|
|
51
|
+
- Error messages are sanitized to exclude secret values
|
|
52
|
+
- File paths are never logged (prevents information disclosure)
|
|
53
|
+
- Path traversal attacks are blocked for file-based secrets
|
|
54
|
+
- Bootstrap secrets bypass normal resolution to prevent circular dependencies
|
|
55
|
+
- Vault paths are never logged (could reveal secret structure)
|
|
56
|
+
|
|
57
|
+
Memory Handling:
|
|
58
|
+
Raw secret values (plain strings) are briefly held in local variables during
|
|
59
|
+
resolution before being wrapped in SecretStr. Python's garbage collector will
|
|
60
|
+
reclaim this memory, but there is no explicit secure memory wiping. This is
|
|
61
|
+
acceptable for most use cases, but for high-security environments:
|
|
62
|
+
|
|
63
|
+
- Consider using dedicated secret management libraries with secure memory handling
|
|
64
|
+
- Use short-lived processes for secret-intensive operations
|
|
65
|
+
- Ensure swap is encrypted at the OS level
|
|
66
|
+
|
|
67
|
+
The brief exposure window is minimized by immediately wrapping values in SecretStr
|
|
68
|
+
after retrieval and never storing raw strings in instance attributes.
|
|
69
|
+
|
|
70
|
+
Vault Integration:
|
|
71
|
+
Vault secrets are resolved via HandlerVault (KV v2 secrets engine only).
|
|
72
|
+
|
|
73
|
+
Path Format: "mount_point/path/to/secret#field"
|
|
74
|
+
- mount_point: The secrets engine mount (e.g., "secret")
|
|
75
|
+
- path: The secret path within the mount (e.g., "myapp/db")
|
|
76
|
+
- field: Optional specific field to extract (e.g., "password")
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
- "secret/myapp/db#password" -> Reads "password" field from secret at myapp/db
|
|
80
|
+
- "secret/myapp/db" -> Reads first field value from secret
|
|
81
|
+
|
|
82
|
+
Type Handling:
|
|
83
|
+
All Vault values are converted to strings. This is intentional because
|
|
84
|
+
SecretResolver returns SecretStr values (which only wrap strings) for
|
|
85
|
+
security. Non-string Vault values are converted via Python's str():
|
|
86
|
+
|
|
87
|
+
- Integers: 123 -> "123"
|
|
88
|
+
- Booleans: True -> "True"
|
|
89
|
+
- Lists/Dicts: Python repr (NOT JSON)
|
|
90
|
+
|
|
91
|
+
Best Practice: Store secrets as strings in Vault. For structured data,
|
|
92
|
+
store as JSON strings and parse after resolution.
|
|
93
|
+
|
|
94
|
+
Graceful Degradation:
|
|
95
|
+
- If vault_handler is None: Returns None with a warning log
|
|
96
|
+
- Vault errors are wrapped in SecretResolutionError with correlation ID
|
|
97
|
+
|
|
98
|
+
Error Handling:
|
|
99
|
+
- InfraAuthenticationError: Auth failures (403)
|
|
100
|
+
- InfraTimeoutError: Request timeouts
|
|
101
|
+
- InfraUnavailableError: Circuit breaker open
|
|
102
|
+
- SecretResolutionError: Other Vault errors (sanitized message)
|
|
103
|
+
|
|
104
|
+
Observability (OMN-1374):
|
|
105
|
+
SecretResolver includes built-in metrics tracking:
|
|
106
|
+
|
|
107
|
+
- Resolution latency by source type (env, file, vault, cache)
|
|
108
|
+
- Cache hit/miss rates (via get_cache_stats())
|
|
109
|
+
- Resolution success/failure counts by source type
|
|
110
|
+
|
|
111
|
+
External metrics collection via ProtocolSecretResolverMetrics:
|
|
112
|
+
metrics_collector = MyPrometheusMetrics()
|
|
113
|
+
resolver = SecretResolver(config=config, metrics_collector=metrics_collector)
|
|
114
|
+
|
|
115
|
+
Structured logging includes:
|
|
116
|
+
- logical_name: The secret being resolved
|
|
117
|
+
- source_type: Where the secret came from
|
|
118
|
+
- cache_hit: Whether it was a cache hit
|
|
119
|
+
- correlation_id: For distributed tracing
|
|
120
|
+
- latency_ms: Resolution time (on success)
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
from __future__ import annotations
|
|
124
|
+
|
|
125
|
+
import asyncio
|
|
126
|
+
import logging
|
|
127
|
+
import os
|
|
128
|
+
import random
|
|
129
|
+
import threading
|
|
130
|
+
import time
|
|
131
|
+
from collections import defaultdict, deque
|
|
132
|
+
from datetime import UTC, datetime, timedelta
|
|
133
|
+
from pathlib import Path
|
|
134
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
135
|
+
from uuid import UUID, uuid4
|
|
136
|
+
|
|
137
|
+
from pydantic import SecretStr
|
|
138
|
+
|
|
139
|
+
from omnibase_core.types import JsonType
|
|
140
|
+
from omnibase_infra.enums import EnumInfraTransportType
|
|
141
|
+
from omnibase_infra.errors import (
|
|
142
|
+
InfraAuthenticationError,
|
|
143
|
+
InfraTimeoutError,
|
|
144
|
+
InfraUnavailableError,
|
|
145
|
+
ModelInfraErrorContext,
|
|
146
|
+
ProtocolConfigurationError,
|
|
147
|
+
SecretResolutionError,
|
|
148
|
+
)
|
|
149
|
+
from omnibase_infra.runtime.models.model_cached_secret import ModelCachedSecret
|
|
150
|
+
from omnibase_infra.runtime.models.model_secret_cache_stats import ModelSecretCacheStats
|
|
151
|
+
from omnibase_infra.runtime.models.model_secret_resolver_config import (
|
|
152
|
+
ModelSecretResolverConfig,
|
|
153
|
+
)
|
|
154
|
+
from omnibase_infra.runtime.models.model_secret_resolver_metrics import (
|
|
155
|
+
ModelSecretResolverMetrics,
|
|
156
|
+
)
|
|
157
|
+
from omnibase_infra.runtime.models.model_secret_source_info import ModelSecretSourceInfo
|
|
158
|
+
from omnibase_infra.runtime.models.model_secret_source_spec import (
|
|
159
|
+
ModelSecretSourceSpec,
|
|
160
|
+
SecretSourceType,
|
|
161
|
+
)
|
|
162
|
+
from omnibase_infra.utils.correlation import generate_correlation_id
|
|
163
|
+
|
|
164
|
+
if TYPE_CHECKING:
|
|
165
|
+
from omnibase_core.container import ModelONEXContainer
|
|
166
|
+
from omnibase_infra.handlers.handler_vault import HandlerVault
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
logger = logging.getLogger(__name__)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@runtime_checkable
|
|
173
|
+
class ProtocolSecretResolverMetrics(Protocol):
|
|
174
|
+
"""Protocol for SecretResolver metrics collection.
|
|
175
|
+
|
|
176
|
+
Implementations can hook into secret resolution operations to collect:
|
|
177
|
+
- Resolution latency by source type (env, file, vault, cache)
|
|
178
|
+
- Cache hit/miss rates
|
|
179
|
+
- Resolution failure counts by source type
|
|
180
|
+
- Success counts by source type
|
|
181
|
+
|
|
182
|
+
All methods are optional (duck-typed). If a method is not implemented,
|
|
183
|
+
the metric simply won't be recorded.
|
|
184
|
+
|
|
185
|
+
Thread Safety:
|
|
186
|
+
Implementations MUST be thread-safe because SecretResolver's
|
|
187
|
+
``_record_resolution_success`` and ``_record_resolution_failure`` methods
|
|
188
|
+
may be called concurrently from multiple threads during parallel
|
|
189
|
+
secret resolution operations. This includes:
|
|
190
|
+
|
|
191
|
+
- Multiple sync callers resolving different secrets simultaneously
|
|
192
|
+
- Async callers running in parallel via ``asyncio.gather``
|
|
193
|
+
- Mixed sync/async access patterns during bootstrap and runtime
|
|
194
|
+
|
|
195
|
+
Thread-Safe Primitives (recommended):
|
|
196
|
+
- ``threading.Lock``: Protects counter increments and dict updates
|
|
197
|
+
- ``threading.RLock``: For reentrant access (if metrics methods call each other)
|
|
198
|
+
- ``collections.Counter`` with lock protection: Convenient for source_type counts
|
|
199
|
+
- ``prometheus_client``: Inherently thread-safe (Counter, Histogram, Gauge)
|
|
200
|
+
- ``queue.Queue``: For async metric collection (producer-consumer pattern)
|
|
201
|
+
|
|
202
|
+
Example Implementation (threading.Lock)::
|
|
203
|
+
|
|
204
|
+
import threading
|
|
205
|
+
from collections import defaultdict
|
|
206
|
+
|
|
207
|
+
class ThreadSafeSecretResolverMetrics:
|
|
208
|
+
'''Minimal thread-safe metrics using threading.Lock.'''
|
|
209
|
+
|
|
210
|
+
def __init__(self) -> None:
|
|
211
|
+
self._lock = threading.Lock()
|
|
212
|
+
self._latencies: list[tuple[str, float]] = []
|
|
213
|
+
self._cache_hits = 0
|
|
214
|
+
self._cache_misses = 0
|
|
215
|
+
self._success_counts: defaultdict[str, int] = defaultdict(int)
|
|
216
|
+
self._failure_counts: defaultdict[str, int] = defaultdict(int)
|
|
217
|
+
|
|
218
|
+
def record_resolution_latency(
|
|
219
|
+
self, source_type: str, latency_ms: float
|
|
220
|
+
) -> None:
|
|
221
|
+
with self._lock:
|
|
222
|
+
self._latencies.append((source_type, latency_ms))
|
|
223
|
+
|
|
224
|
+
def record_cache_hit(self) -> None:
|
|
225
|
+
with self._lock:
|
|
226
|
+
self._cache_hits += 1
|
|
227
|
+
|
|
228
|
+
def record_cache_miss(self) -> None:
|
|
229
|
+
with self._lock:
|
|
230
|
+
self._cache_misses += 1
|
|
231
|
+
|
|
232
|
+
def record_resolution_success(self, source_type: str) -> None:
|
|
233
|
+
with self._lock:
|
|
234
|
+
self._success_counts[source_type] += 1
|
|
235
|
+
|
|
236
|
+
def record_resolution_failure(self, source_type: str) -> None:
|
|
237
|
+
with self._lock:
|
|
238
|
+
self._failure_counts[source_type] += 1
|
|
239
|
+
|
|
240
|
+
Example Implementation (prometheus_client)::
|
|
241
|
+
|
|
242
|
+
from prometheus_client import Counter, Histogram
|
|
243
|
+
|
|
244
|
+
class PrometheusSecretResolverMetrics:
|
|
245
|
+
def __init__(self) -> None:
|
|
246
|
+
self._latency = Histogram(
|
|
247
|
+
'secret_resolution_latency_ms',
|
|
248
|
+
'Latency of secret resolution in milliseconds',
|
|
249
|
+
['source_type'],
|
|
250
|
+
)
|
|
251
|
+
self._cache_hits = Counter('secret_cache_hits_total', 'Cache hits')
|
|
252
|
+
self._cache_misses = Counter('secret_cache_misses_total', 'Cache misses')
|
|
253
|
+
self._failures = Counter(
|
|
254
|
+
'secret_resolution_failures_total',
|
|
255
|
+
'Resolution failures',
|
|
256
|
+
['source_type'],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def record_resolution_latency(
|
|
260
|
+
self, source_type: str, latency_ms: float
|
|
261
|
+
) -> None:
|
|
262
|
+
self._latency.labels(source_type=source_type).observe(latency_ms)
|
|
263
|
+
|
|
264
|
+
def record_cache_hit(self) -> None:
|
|
265
|
+
self._cache_hits.inc()
|
|
266
|
+
|
|
267
|
+
def record_cache_miss(self) -> None:
|
|
268
|
+
self._cache_misses.inc()
|
|
269
|
+
|
|
270
|
+
def record_resolution_failure(self, source_type: str) -> None:
|
|
271
|
+
self._failures.labels(source_type=source_type).inc()
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def record_resolution_latency(self, source_type: str, latency_ms: float) -> None:
|
|
275
|
+
"""Record latency for a secret resolution operation.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
source_type: Source type (env, file, vault, cache)
|
|
279
|
+
latency_ms: Time in milliseconds for the operation
|
|
280
|
+
"""
|
|
281
|
+
...
|
|
282
|
+
|
|
283
|
+
def record_cache_hit(self) -> None:
|
|
284
|
+
"""Record a cache hit."""
|
|
285
|
+
...
|
|
286
|
+
|
|
287
|
+
def record_cache_miss(self) -> None:
|
|
288
|
+
"""Record a cache miss."""
|
|
289
|
+
...
|
|
290
|
+
|
|
291
|
+
def record_resolution_success(self, source_type: str) -> None:
|
|
292
|
+
"""Record a successful resolution.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
source_type: Source type (env, file, vault)
|
|
296
|
+
"""
|
|
297
|
+
...
|
|
298
|
+
|
|
299
|
+
def record_resolution_failure(self, source_type: str) -> None:
|
|
300
|
+
"""Record a resolution failure.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
source_type: Source type (env, file, vault)
|
|
304
|
+
"""
|
|
305
|
+
...
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Maximum file size for secret files (1MB)
|
|
309
|
+
# Prevents memory exhaustion from accidentally pointing at large files
|
|
310
|
+
MAX_SECRET_FILE_SIZE = 1024 * 1024
|
|
311
|
+
|
|
312
|
+
# Maximum latency samples to retain for metrics (rolling window)
|
|
313
|
+
MAX_LATENCY_SAMPLES = 1000
|
|
314
|
+
|
|
315
|
+
# Warning threshold for async key locks dictionary size (DoS mitigation)
|
|
316
|
+
ASYNC_KEY_LOCKS_WARNING_THRESHOLD = 1000
|
|
317
|
+
|
|
318
|
+
# Maximum async key locks before LRU eviction (memory leak prevention)
|
|
319
|
+
# When this limit is reached, oldest 10% of entries are evicted
|
|
320
|
+
MAX_ASYNC_KEY_LOCKS = 1000
|
|
321
|
+
|
|
322
|
+
# Cache TTL jitter percentage for symmetric ±10% jitter (stampede prevention)
|
|
323
|
+
CACHE_TTL_JITTER_PERCENT = 0.1
|
|
324
|
+
|
|
325
|
+
# Rate limit interval for LRU eviction warnings (prevents log flooding)
|
|
326
|
+
EVICTION_WARNING_INTERVAL_SECONDS = 60.0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SecretResolver:
|
|
330
|
+
"""Centralized secret resolution. Dumb and deterministic.
|
|
331
|
+
|
|
332
|
+
The SecretResolver provides a unified interface for accessing secrets from
|
|
333
|
+
multiple sources with caching and optional convention-based fallback.
|
|
334
|
+
|
|
335
|
+
Resolution Order:
|
|
336
|
+
1. Check cache (if not expired)
|
|
337
|
+
2. Try explicit mapping from configuration
|
|
338
|
+
3. Try convention fallback (if enabled): logical_name -> ENV_VAR
|
|
339
|
+
4. Raise or return None based on required flag
|
|
340
|
+
|
|
341
|
+
Thread Safety:
|
|
342
|
+
This class supports concurrent access from both sync and async contexts
|
|
343
|
+
using a two-level locking strategy:
|
|
344
|
+
|
|
345
|
+
1. ``threading.RLock`` (``_lock``): Protects all cache reads/writes and
|
|
346
|
+
stats updates. This lock is held briefly for in-memory operations.
|
|
347
|
+
|
|
348
|
+
2. Per-key ``asyncio.Lock`` (``_async_key_locks``): Prevents duplicate
|
|
349
|
+
async fetches for the SAME secret. When multiple async callers request
|
|
350
|
+
the same secret simultaneously, only one performs the fetch while
|
|
351
|
+
others wait and reuse the cached result. Different secrets can be
|
|
352
|
+
fetched in parallel.
|
|
353
|
+
|
|
354
|
+
Sync/Async Coordination:
|
|
355
|
+
- Sync ``get_secret``: Holds ``_lock`` for entire operation (cache
|
|
356
|
+
check through cache write). This ensures atomicity but may briefly
|
|
357
|
+
block async callers during cache access.
|
|
358
|
+
- Async ``get_secret_async``: Uses per-key async locks to serialize
|
|
359
|
+
fetches for the same key, with ``_lock`` held only briefly for
|
|
360
|
+
cache access. This allows parallel fetches for different secrets.
|
|
361
|
+
|
|
362
|
+
Edge Case - Sync/Async Race:
|
|
363
|
+
Due to the different locking granularity between sync (holds lock
|
|
364
|
+
during I/O) and async (releases lock during I/O), there's a small
|
|
365
|
+
window where both sync and async code might resolve the same secret
|
|
366
|
+
simultaneously. This is handled by a check-before-write pattern:
|
|
367
|
+
before caching, we verify the key isn't already present. If a sync
|
|
368
|
+
caller won the race, we skip the redundant cache write.
|
|
369
|
+
|
|
370
|
+
Why this race is acceptable:
|
|
371
|
+
1. Both sync and async resolvers fetch the same value from the same
|
|
372
|
+
source (env var, file, or Vault path), so the resolved values are
|
|
373
|
+
always identical.
|
|
374
|
+
2. The skip-if-present check prevents wasted cache writes, but even
|
|
375
|
+
without it, last-write-wins produces correct results since all
|
|
376
|
+
writers have the same value.
|
|
377
|
+
3. There is no correctness issue - only a minor inefficiency of
|
|
378
|
+
potentially resolving the same secret twice in rare cases.
|
|
379
|
+
|
|
380
|
+
Bootstrap Secret Isolation:
|
|
381
|
+
Bootstrap secrets (vault.token, vault.addr, vault.ca_cert) are
|
|
382
|
+
resolved exclusively from environment variables, never from Vault
|
|
383
|
+
or files. This prevents circular dependencies during Vault init.
|
|
384
|
+
The resolution path is isolated from regular secrets, and cache
|
|
385
|
+
writes are always protected by ``_lock`` in both sync and async
|
|
386
|
+
contexts.
|
|
387
|
+
|
|
388
|
+
Example:
|
|
389
|
+
>>> config = ModelSecretResolverConfig(
|
|
390
|
+
... mappings=[
|
|
391
|
+
... ModelSecretMapping(
|
|
392
|
+
... logical_name="database.postgres.password",
|
|
393
|
+
... source=ModelSecretSourceSpec(
|
|
394
|
+
... source_type="env",
|
|
395
|
+
... source_path="POSTGRES_PASSWORD"
|
|
396
|
+
... )
|
|
397
|
+
... )
|
|
398
|
+
... ]
|
|
399
|
+
... )
|
|
400
|
+
>>> resolver = SecretResolver(config=config)
|
|
401
|
+
>>> password = resolver.get_secret("database.postgres.password")
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
config: ModelSecretResolverConfig,
|
|
407
|
+
vault_handler: HandlerVault | None = None,
|
|
408
|
+
metrics_collector: ProtocolSecretResolverMetrics | None = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Initialize SecretResolver.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
config: Resolver configuration with mappings and TTLs
|
|
414
|
+
vault_handler: Optional Vault handler for Vault-sourced secrets
|
|
415
|
+
metrics_collector: Optional external metrics collector for observability
|
|
416
|
+
|
|
417
|
+
Note:
|
|
418
|
+
For ONEX applications using ``ModelONEXContainer``, consider resolving
|
|
419
|
+
dependencies via container-based DI rather than direct constructor
|
|
420
|
+
injection. This enables centralized lifecycle management and consistent
|
|
421
|
+
dependency resolution across the application. The current explicit
|
|
422
|
+
constructor parameters are retained for flexibility in standalone usage
|
|
423
|
+
and testing scenarios.
|
|
424
|
+
"""
|
|
425
|
+
self._config = config
|
|
426
|
+
self._vault_handler = vault_handler
|
|
427
|
+
self._metrics_collector = metrics_collector
|
|
428
|
+
self._cache: dict[str, ModelCachedSecret] = {}
|
|
429
|
+
# Track mutable stats internally since ModelSecretCacheStats is frozen
|
|
430
|
+
self._hits = 0
|
|
431
|
+
self._misses = 0
|
|
432
|
+
self._expired_evictions = 0
|
|
433
|
+
self._refreshes = 0
|
|
434
|
+
self._hit_counts: defaultdict[str, int] = defaultdict(int) # per logical_name
|
|
435
|
+
# RLock (reentrant lock) allows the same thread to acquire the lock
|
|
436
|
+
# multiple times, which is needed because get_secret() holds the lock
|
|
437
|
+
# while calling _record_resolution_success() which also needs the lock.
|
|
438
|
+
self._lock = threading.RLock()
|
|
439
|
+
# Per-key async locks to allow parallel fetches for different secrets
|
|
440
|
+
# while preventing duplicate fetches for the same secret
|
|
441
|
+
self._async_key_locks: dict[str, asyncio.Lock] = {}
|
|
442
|
+
|
|
443
|
+
# === Metrics Tracking (OMN-1374) ===
|
|
444
|
+
# Resolution latency tracking (deque of (source_type, latency_ms) tuples)
|
|
445
|
+
self._resolution_latencies: deque[tuple[str, float]] = deque(
|
|
446
|
+
maxlen=MAX_LATENCY_SAMPLES
|
|
447
|
+
)
|
|
448
|
+
# Resolution success/failure counts by source type
|
|
449
|
+
self._resolution_success_counts: defaultdict[str, int] = defaultdict(int)
|
|
450
|
+
self._resolution_failure_counts: defaultdict[str, int] = defaultdict(int)
|
|
451
|
+
|
|
452
|
+
# === Rate Limiting for Warnings ===
|
|
453
|
+
# Track last eviction warning time to rate-limit log output
|
|
454
|
+
self._last_eviction_warning_time: float = 0.0
|
|
455
|
+
|
|
456
|
+
# Build lookup table from mappings
|
|
457
|
+
self._mappings: dict[str, ModelSecretSourceSpec] = {
|
|
458
|
+
m.logical_name: m.source for m in config.mappings
|
|
459
|
+
}
|
|
460
|
+
self._ttl_overrides: dict[str, int] = {
|
|
461
|
+
m.logical_name: m.ttl_seconds
|
|
462
|
+
for m in config.mappings
|
|
463
|
+
if m.ttl_seconds is not None
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# === Container-Based Factory ===
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
async def from_container(
|
|
470
|
+
cls,
|
|
471
|
+
container: ModelONEXContainer,
|
|
472
|
+
config: ModelSecretResolverConfig,
|
|
473
|
+
) -> SecretResolver:
|
|
474
|
+
"""Create SecretResolver from ONEX container with dependency injection.
|
|
475
|
+
|
|
476
|
+
This async factory method supports the ONEX container-based dependency
|
|
477
|
+
injection pattern while the regular constructor remains available for
|
|
478
|
+
standalone use and testing scenarios.
|
|
479
|
+
|
|
480
|
+
The factory attempts to resolve optional dependencies (HandlerVault,
|
|
481
|
+
metrics collector) from the container's service registry. If a dependency
|
|
482
|
+
is not registered, the resolver is created without it - this allows
|
|
483
|
+
graceful degradation when optional services are unavailable.
|
|
484
|
+
|
|
485
|
+
Parameters:
|
|
486
|
+
This factory method accepts 2 parameters (both required):
|
|
487
|
+
|
|
488
|
+
- ``container`` (required): The ONEX container with registered services
|
|
489
|
+
- ``config`` (required): Resolver configuration with secret mappings
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
container: ONEX dependency injection container. May have HandlerVault
|
|
493
|
+
and/or ProtocolSecretResolverMetrics registered in its service
|
|
494
|
+
registry. These are resolved if available but not required.
|
|
495
|
+
config: Resolver configuration specifying secret mappings, default TTL,
|
|
496
|
+
and convention fallback settings. This is required because secret
|
|
497
|
+
mappings are application-specific and cannot be auto-discovered
|
|
498
|
+
from the container.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Configured SecretResolver instance with container-resolved dependencies.
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
ProtocolConfigurationError: If the container is invalid or missing
|
|
505
|
+
the required ``service_registry`` attribute. The error includes
|
|
506
|
+
:class:`ModelInfraErrorContext` with correlation_id for tracing.
|
|
507
|
+
|
|
508
|
+
Example:
|
|
509
|
+
Basic usage with container-resolved dependencies::
|
|
510
|
+
|
|
511
|
+
container = ModelONEXContainer()
|
|
512
|
+
await wire_infrastructure_services(container)
|
|
513
|
+
|
|
514
|
+
config = ModelSecretResolverConfig(
|
|
515
|
+
mappings=[
|
|
516
|
+
ModelSecretMapping(
|
|
517
|
+
logical_name="database.password",
|
|
518
|
+
source=ModelSecretSourceSpec(
|
|
519
|
+
source_type=SecretSourceType.VAULT,
|
|
520
|
+
path="secret/myapp/db#password",
|
|
521
|
+
),
|
|
522
|
+
),
|
|
523
|
+
],
|
|
524
|
+
)
|
|
525
|
+
resolver = await SecretResolver.from_container(container, config)
|
|
526
|
+
password = await resolver.get_secret_async("database.password")
|
|
527
|
+
|
|
528
|
+
Example (Container Without Optional Services):
|
|
529
|
+
If HandlerVault is not registered, Vault-sourced secrets will fail
|
|
530
|
+
at resolution time (not at factory creation)::
|
|
531
|
+
|
|
532
|
+
container = ModelONEXContainer()
|
|
533
|
+
# No wire_infrastructure_services() call - no Vault handler
|
|
534
|
+
resolver = await SecretResolver.from_container(container, config)
|
|
535
|
+
# Works for env/file secrets, fails for Vault secrets
|
|
536
|
+
|
|
537
|
+
Note:
|
|
538
|
+
**Why config is a required parameter:**
|
|
539
|
+
|
|
540
|
+
Unlike other services that can be fully configured via container
|
|
541
|
+
registration, SecretResolver requires explicit secret mappings that
|
|
542
|
+
are application-specific. The config defines:
|
|
543
|
+
|
|
544
|
+
- Which logical names map to which secret sources
|
|
545
|
+
- Default TTL for caching
|
|
546
|
+
- Whether convention fallback is enabled
|
|
547
|
+
|
|
548
|
+
These settings vary per application and cannot be auto-discovered
|
|
549
|
+
from the container's service registry.
|
|
550
|
+
|
|
551
|
+
See Also:
|
|
552
|
+
- :meth:`__init__`: Direct constructor for standalone usage
|
|
553
|
+
- :class:`ModelSecretResolverConfig`: Configuration model
|
|
554
|
+
- :class:`HandlerVault`: Vault handler for Vault-sourced secrets
|
|
555
|
+
"""
|
|
556
|
+
from omnibase_infra.handlers.handler_vault import HandlerVault
|
|
557
|
+
|
|
558
|
+
correlation_id = generate_correlation_id()
|
|
559
|
+
|
|
560
|
+
# Validate container has service_registry
|
|
561
|
+
if not hasattr(container, "service_registry"):
|
|
562
|
+
context = ModelInfraErrorContext.with_correlation(
|
|
563
|
+
correlation_id=correlation_id,
|
|
564
|
+
transport_type=EnumInfraTransportType.RUNTIME,
|
|
565
|
+
operation="from_container",
|
|
566
|
+
target_name="SecretResolver",
|
|
567
|
+
)
|
|
568
|
+
raise ProtocolConfigurationError(
|
|
569
|
+
"Container missing required 'service_registry' attribute. "
|
|
570
|
+
"Ensure container is a valid ModelONEXContainer instance.",
|
|
571
|
+
context=context,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Try to resolve optional dependencies from container
|
|
575
|
+
vault_handler: HandlerVault | None = None
|
|
576
|
+
metrics_collector: ProtocolSecretResolverMetrics | None = None
|
|
577
|
+
|
|
578
|
+
# Attempt to resolve HandlerVault (optional)
|
|
579
|
+
try:
|
|
580
|
+
vault_handler = await container.service_registry.resolve_service(
|
|
581
|
+
HandlerVault
|
|
582
|
+
)
|
|
583
|
+
logger.debug(
|
|
584
|
+
"Resolved HandlerVault from container",
|
|
585
|
+
extra={"correlation_id": str(correlation_id)},
|
|
586
|
+
)
|
|
587
|
+
except Exception as e:
|
|
588
|
+
# HandlerVault not registered - this is acceptable
|
|
589
|
+
logger.debug(
|
|
590
|
+
"HandlerVault not available in container, Vault secrets disabled: %s",
|
|
591
|
+
type(e).__name__,
|
|
592
|
+
extra={"correlation_id": str(correlation_id)},
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# Attempt to resolve metrics collector (optional)
|
|
596
|
+
# Note: We use a broad try/except since the protocol may not be registered
|
|
597
|
+
try:
|
|
598
|
+
metrics_collector = await container.service_registry.resolve_service(
|
|
599
|
+
ProtocolSecretResolverMetrics # type: ignore[type-abstract]
|
|
600
|
+
)
|
|
601
|
+
logger.debug(
|
|
602
|
+
"Resolved ProtocolSecretResolverMetrics from container",
|
|
603
|
+
extra={"correlation_id": str(correlation_id)},
|
|
604
|
+
)
|
|
605
|
+
except Exception as e:
|
|
606
|
+
# Metrics collector not registered - this is acceptable
|
|
607
|
+
logger.debug(
|
|
608
|
+
"Metrics collector not available in container: %s",
|
|
609
|
+
type(e).__name__,
|
|
610
|
+
extra={"correlation_id": str(correlation_id)},
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
return cls(
|
|
614
|
+
config=config,
|
|
615
|
+
vault_handler=vault_handler,
|
|
616
|
+
metrics_collector=metrics_collector,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# === Primary API (Sync) ===
|
|
620
|
+
|
|
621
|
+
def get_secret(
|
|
622
|
+
self,
|
|
623
|
+
logical_name: str,
|
|
624
|
+
required: bool = True,
|
|
625
|
+
correlation_id: UUID | None = None,
|
|
626
|
+
) -> SecretStr | None:
|
|
627
|
+
"""Resolve a secret by logical name.
|
|
628
|
+
|
|
629
|
+
Resolution order:
|
|
630
|
+
1. Check cache (if not expired)
|
|
631
|
+
2. Try explicit mapping
|
|
632
|
+
3. Try convention fallback (if enabled)
|
|
633
|
+
4. Raise or return None based on required flag
|
|
634
|
+
|
|
635
|
+
Warning:
|
|
636
|
+
This synchronous method cannot resolve Vault secrets from within
|
|
637
|
+
an async context (e.g., from inside an async function or coroutine).
|
|
638
|
+
If you need to resolve Vault secrets in async code, use
|
|
639
|
+
``get_secret_async()`` instead. Calling this method from async
|
|
640
|
+
context when resolving Vault secrets will raise SecretResolutionError.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
logical_name: Dotted path (e.g., "database.postgres.password")
|
|
644
|
+
required: If True, raises SecretResolutionError when not found
|
|
645
|
+
correlation_id: Optional correlation ID for distributed tracing.
|
|
646
|
+
If provided, propagates to error context for debugging.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
SecretStr if found, None if not found and required=False
|
|
650
|
+
|
|
651
|
+
Raises:
|
|
652
|
+
SecretResolutionError: If required=True and secret not found
|
|
653
|
+
"""
|
|
654
|
+
# Generate correlation ID if not provided (for metrics/logging)
|
|
655
|
+
effective_correlation_id = correlation_id or uuid4()
|
|
656
|
+
|
|
657
|
+
with self._lock:
|
|
658
|
+
# Check cache first
|
|
659
|
+
cached = self._get_from_cache(logical_name)
|
|
660
|
+
if cached is not None:
|
|
661
|
+
self._record_resolution_success(
|
|
662
|
+
logical_name, "cache", effective_correlation_id
|
|
663
|
+
)
|
|
664
|
+
return cached
|
|
665
|
+
|
|
666
|
+
# Resolve from source
|
|
667
|
+
result = self._resolve_secret(logical_name, effective_correlation_id)
|
|
668
|
+
|
|
669
|
+
if result is None:
|
|
670
|
+
self._misses += 1
|
|
671
|
+
if required:
|
|
672
|
+
context = ModelInfraErrorContext.with_correlation(
|
|
673
|
+
correlation_id=effective_correlation_id,
|
|
674
|
+
transport_type=EnumInfraTransportType.RUNTIME,
|
|
675
|
+
operation="get_secret",
|
|
676
|
+
target_name="secret_resolver",
|
|
677
|
+
)
|
|
678
|
+
# SECURITY: Log at DEBUG level only to avoid exposing secret identifiers
|
|
679
|
+
# in error messages shown to users/logs
|
|
680
|
+
logger.debug(
|
|
681
|
+
"Secret not found (correlation_id=%s): %s",
|
|
682
|
+
context.correlation_id,
|
|
683
|
+
logical_name,
|
|
684
|
+
extra={
|
|
685
|
+
"correlation_id": str(context.correlation_id),
|
|
686
|
+
"logical_name": logical_name,
|
|
687
|
+
},
|
|
688
|
+
)
|
|
689
|
+
# SECURITY: Do NOT include logical_name in error - it exposes secret identifiers
|
|
690
|
+
# Use correlation_id to trace back to DEBUG logs if needed
|
|
691
|
+
raise SecretResolutionError(
|
|
692
|
+
f"Required secret not found. "
|
|
693
|
+
f"See logs with correlation_id={context.correlation_id} for details.",
|
|
694
|
+
context=context,
|
|
695
|
+
# NOTE: Intentionally NOT passing logical_name to avoid exposing
|
|
696
|
+
# secret identifiers in error messages, logs, or serialized responses.
|
|
697
|
+
)
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
return result
|
|
701
|
+
|
|
702
|
+
def get_secrets(
|
|
703
|
+
self,
|
|
704
|
+
logical_names: list[str],
|
|
705
|
+
required: bool = True,
|
|
706
|
+
correlation_id: UUID | None = None,
|
|
707
|
+
) -> dict[str, SecretStr | None]:
|
|
708
|
+
"""Resolve multiple secrets.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
logical_names: List of dotted paths
|
|
712
|
+
required: If True, raises on first missing secret
|
|
713
|
+
correlation_id: Optional correlation ID for distributed tracing.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
Dict mapping logical_name -> SecretStr | None
|
|
717
|
+
|
|
718
|
+
Note:
|
|
719
|
+
This sync method resolves secrets sequentially. For better latency
|
|
720
|
+
when resolving multiple secrets that involve I/O (Vault, file-based),
|
|
721
|
+
prefer using ``get_secrets_async()`` which resolves in parallel via
|
|
722
|
+
``asyncio.gather()``.
|
|
723
|
+
"""
|
|
724
|
+
return {
|
|
725
|
+
name: self.get_secret(
|
|
726
|
+
name, required=required, correlation_id=correlation_id
|
|
727
|
+
)
|
|
728
|
+
for name in logical_names
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
# === Primary API (Async) ===
|
|
732
|
+
|
|
733
|
+
async def get_secret_async(
|
|
734
|
+
self,
|
|
735
|
+
logical_name: str,
|
|
736
|
+
required: bool = True,
|
|
737
|
+
correlation_id: UUID | None = None,
|
|
738
|
+
) -> SecretStr | None:
|
|
739
|
+
"""Async wrapper for get_secret.
|
|
740
|
+
|
|
741
|
+
For Vault secrets, this uses async I/O. For env/file secrets,
|
|
742
|
+
this wraps the sync call in a thread executor.
|
|
743
|
+
|
|
744
|
+
Thread Safety:
|
|
745
|
+
Uses threading.RLock for cache access to prevent race conditions
|
|
746
|
+
with sync callers. Per-key async locks serialize resolution for the
|
|
747
|
+
same secret while allowing parallel fetches for different secrets.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
logical_name: Dotted path (e.g., "database.postgres.password")
|
|
751
|
+
required: If True, raises SecretResolutionError when not found
|
|
752
|
+
correlation_id: Optional correlation ID for distributed tracing.
|
|
753
|
+
If provided, propagates to error context for debugging.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
SecretStr if found, None if not found and required=False
|
|
757
|
+
|
|
758
|
+
Raises:
|
|
759
|
+
SecretResolutionError: If required=True and secret not found
|
|
760
|
+
"""
|
|
761
|
+
# Generate correlation ID if not provided (for metrics/logging)
|
|
762
|
+
effective_correlation_id = correlation_id or uuid4()
|
|
763
|
+
|
|
764
|
+
# Use threading lock for cache check (fast operation, prevents race with sync)
|
|
765
|
+
with self._lock:
|
|
766
|
+
cached = self._get_from_cache(logical_name)
|
|
767
|
+
if cached is not None:
|
|
768
|
+
self._record_resolution_success(
|
|
769
|
+
logical_name, "cache", effective_correlation_id
|
|
770
|
+
)
|
|
771
|
+
return cached
|
|
772
|
+
|
|
773
|
+
# Get or create per-key async lock for this logical_name
|
|
774
|
+
# This allows parallel fetches for different secrets while preventing
|
|
775
|
+
# duplicate fetches for the same secret
|
|
776
|
+
key_lock = self._get_async_key_lock(logical_name)
|
|
777
|
+
|
|
778
|
+
async with key_lock:
|
|
779
|
+
# Double-check cache after acquiring async lock - another coroutine may
|
|
780
|
+
# have resolved this secret while we were waiting on the lock
|
|
781
|
+
with self._lock:
|
|
782
|
+
cached = self._get_from_cache(logical_name)
|
|
783
|
+
if cached is not None:
|
|
784
|
+
self._record_resolution_success(
|
|
785
|
+
logical_name, "cache", effective_correlation_id
|
|
786
|
+
)
|
|
787
|
+
return cached
|
|
788
|
+
|
|
789
|
+
# Resolve from source (potentially async for Vault)
|
|
790
|
+
# Note: _resolve_secret_async handles its own locking for cache writes
|
|
791
|
+
result = await self._resolve_secret_async(
|
|
792
|
+
logical_name, effective_correlation_id
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
if result is None:
|
|
796
|
+
with self._lock:
|
|
797
|
+
self._misses += 1
|
|
798
|
+
if required:
|
|
799
|
+
context = ModelInfraErrorContext.with_correlation(
|
|
800
|
+
correlation_id=effective_correlation_id,
|
|
801
|
+
transport_type=EnumInfraTransportType.RUNTIME,
|
|
802
|
+
operation="get_secret_async",
|
|
803
|
+
target_name="secret_resolver",
|
|
804
|
+
)
|
|
805
|
+
# SECURITY: Log at DEBUG level only to avoid exposing secret identifiers
|
|
806
|
+
# in error messages shown to users/logs
|
|
807
|
+
logger.debug(
|
|
808
|
+
"Secret not found (correlation_id=%s): %s",
|
|
809
|
+
context.correlation_id,
|
|
810
|
+
logical_name,
|
|
811
|
+
extra={
|
|
812
|
+
"correlation_id": str(context.correlation_id),
|
|
813
|
+
"logical_name": logical_name,
|
|
814
|
+
},
|
|
815
|
+
)
|
|
816
|
+
# SECURITY: Do NOT include logical_name in error - it exposes secret identifiers
|
|
817
|
+
# Use correlation_id to trace back to DEBUG logs if needed
|
|
818
|
+
raise SecretResolutionError(
|
|
819
|
+
f"Required secret not found. "
|
|
820
|
+
f"See logs with correlation_id={context.correlation_id} for details.",
|
|
821
|
+
context=context,
|
|
822
|
+
# NOTE: Intentionally NOT passing logical_name to avoid exposing
|
|
823
|
+
# secret identifiers in error messages, logs, or serialized responses.
|
|
824
|
+
)
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
return result
|
|
828
|
+
|
|
829
|
+
def _maybe_log_eviction_warning(self, evict_count: int) -> None:
|
|
830
|
+
"""Log eviction warning with rate limiting (max once per minute).
|
|
831
|
+
|
|
832
|
+
Prevents log flooding in high-throughput scenarios where evictions
|
|
833
|
+
may occur frequently. The warning is only emitted if sufficient time
|
|
834
|
+
has passed since the last warning.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
evict_count: Number of entries that were evicted.
|
|
838
|
+
|
|
839
|
+
Note:
|
|
840
|
+
Uses time.monotonic() for reliable elapsed time measurement
|
|
841
|
+
even if system clock changes.
|
|
842
|
+
"""
|
|
843
|
+
current_time = time.monotonic()
|
|
844
|
+
if (
|
|
845
|
+
current_time - self._last_eviction_warning_time
|
|
846
|
+
>= EVICTION_WARNING_INTERVAL_SECONDS
|
|
847
|
+
):
|
|
848
|
+
self._last_eviction_warning_time = current_time
|
|
849
|
+
logger.warning(
|
|
850
|
+
"Async key locks at capacity (%d). Evicted %d oldest entries. "
|
|
851
|
+
"This may indicate a DoS attack or dynamic logical name generation. "
|
|
852
|
+
"Consider validating logical names against configured mappings. "
|
|
853
|
+
"(Rate-limited: max 1 warning per minute)",
|
|
854
|
+
MAX_ASYNC_KEY_LOCKS,
|
|
855
|
+
evict_count,
|
|
856
|
+
extra={
|
|
857
|
+
"max_locks": MAX_ASYNC_KEY_LOCKS,
|
|
858
|
+
"evicted_count": evict_count,
|
|
859
|
+
"current_count": len(self._async_key_locks),
|
|
860
|
+
},
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
def _get_async_key_lock(self, logical_name: str) -> asyncio.Lock:
|
|
864
|
+
"""Get or create an async lock for a specific logical_name.
|
|
865
|
+
|
|
866
|
+
This enables parallel resolution of different secrets while preventing
|
|
867
|
+
duplicate concurrent fetches for the same secret.
|
|
868
|
+
|
|
869
|
+
Thread Safety:
|
|
870
|
+
Uses threading.RLock to safely access the key locks dictionary,
|
|
871
|
+
ensuring thread-safe creation of new locks.
|
|
872
|
+
|
|
873
|
+
LRU Eviction (Memory Leak Prevention):
|
|
874
|
+
The ``_async_key_locks`` dictionary implements LRU eviction to prevent
|
|
875
|
+
unbounded memory growth. When the dictionary reaches ``MAX_ASYNC_KEY_LOCKS``
|
|
876
|
+
entries:
|
|
877
|
+
|
|
878
|
+
1. The oldest 10% of entries are evicted (based on insertion order)
|
|
879
|
+
2. A warning is logged indicating potential DoS or misconfiguration
|
|
880
|
+
3. The new lock is then added
|
|
881
|
+
|
|
882
|
+
This ensures memory usage is bounded while maintaining correctness:
|
|
883
|
+
- Evicted locks are for secrets that were resolved earlier
|
|
884
|
+
- If a secret is resolved again, a new lock will be created
|
|
885
|
+
- The worst case is a brief period of duplicate concurrent fetches
|
|
886
|
+
for recently-evicted secrets, which is acceptable (same value resolved)
|
|
887
|
+
|
|
888
|
+
DoS Mitigation:
|
|
889
|
+
- Warning threshold at ``ASYNC_KEY_LOCKS_WARNING_THRESHOLD`` for early detection
|
|
890
|
+
- Hard cap at ``MAX_ASYNC_KEY_LOCKS`` with LRU eviction
|
|
891
|
+
- Repeated eviction warnings indicate potential attack or misconfiguration
|
|
892
|
+
- Validate logical names against configured mappings to prevent abuse
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
logical_name: The secret key to get a lock for
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
asyncio.Lock for the given logical_name
|
|
899
|
+
"""
|
|
900
|
+
with self._lock:
|
|
901
|
+
if logical_name not in self._async_key_locks:
|
|
902
|
+
lock_count = len(self._async_key_locks)
|
|
903
|
+
|
|
904
|
+
# DoS mitigation: warn at threshold for early detection
|
|
905
|
+
if lock_count == ASYNC_KEY_LOCKS_WARNING_THRESHOLD:
|
|
906
|
+
logger.warning(
|
|
907
|
+
"Async key locks dictionary reached %d entries - potential DoS risk. "
|
|
908
|
+
"Validate logical names against configured mappings.",
|
|
909
|
+
lock_count,
|
|
910
|
+
extra={"lock_count": lock_count},
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# LRU eviction: when at capacity, evict oldest 10% of entries
|
|
914
|
+
if lock_count >= MAX_ASYNC_KEY_LOCKS:
|
|
915
|
+
evict_count = max(1, MAX_ASYNC_KEY_LOCKS // 10) # 10%, minimum 1
|
|
916
|
+
# Python 3.7+ dicts maintain insertion order, so first keys are oldest
|
|
917
|
+
keys_to_evict = list(self._async_key_locks.keys())[:evict_count]
|
|
918
|
+
for key in keys_to_evict:
|
|
919
|
+
del self._async_key_locks[key]
|
|
920
|
+
|
|
921
|
+
# Rate-limited warning (max 1 per minute) to prevent log flooding
|
|
922
|
+
self._maybe_log_eviction_warning(evict_count)
|
|
923
|
+
|
|
924
|
+
self._async_key_locks[logical_name] = asyncio.Lock()
|
|
925
|
+
|
|
926
|
+
return self._async_key_locks[logical_name]
|
|
927
|
+
|
|
928
|
+
async def get_secrets_async(
|
|
929
|
+
self,
|
|
930
|
+
logical_names: list[str],
|
|
931
|
+
required: bool = True,
|
|
932
|
+
correlation_id: UUID | None = None,
|
|
933
|
+
) -> dict[str, SecretStr | None]:
|
|
934
|
+
"""Resolve multiple secrets asynchronously in parallel.
|
|
935
|
+
|
|
936
|
+
Uses asyncio.gather() to fetch multiple secrets concurrently, improving
|
|
937
|
+
performance when resolving multiple secrets that may involve I/O (e.g.,
|
|
938
|
+
Vault or file-based secrets).
|
|
939
|
+
|
|
940
|
+
Thread Safety:
|
|
941
|
+
Each secret resolution uses per-key async locks, so fetches for
|
|
942
|
+
different secrets proceed in parallel while fetches for the same
|
|
943
|
+
secret are serialized.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
logical_names: List of dotted paths
|
|
947
|
+
required: If True, aggregates all failures into a single error
|
|
948
|
+
correlation_id: Optional correlation ID for distributed tracing.
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
Dict mapping logical_name -> SecretStr | None
|
|
952
|
+
|
|
953
|
+
Raises:
|
|
954
|
+
SecretResolutionError: If required=True and any secret is not found.
|
|
955
|
+
All secrets are attempted before raising, and all failures are
|
|
956
|
+
reported in a single aggregated error message.
|
|
957
|
+
"""
|
|
958
|
+
if not logical_names:
|
|
959
|
+
return {}
|
|
960
|
+
|
|
961
|
+
# Create tasks for parallel resolution
|
|
962
|
+
tasks = [
|
|
963
|
+
self.get_secret_async(
|
|
964
|
+
name, required=required, correlation_id=correlation_id
|
|
965
|
+
)
|
|
966
|
+
for name in logical_names
|
|
967
|
+
]
|
|
968
|
+
|
|
969
|
+
# Gather results with return_exceptions=True for better error aggregation
|
|
970
|
+
# This ensures all secrets are attempted before any error is raised
|
|
971
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
972
|
+
|
|
973
|
+
# Check for and aggregate exceptions
|
|
974
|
+
failed_secrets: list[str] = []
|
|
975
|
+
successful_results: dict[str, SecretStr | None] = {}
|
|
976
|
+
|
|
977
|
+
for name, result in zip(logical_names, results, strict=True):
|
|
978
|
+
if isinstance(result, BaseException):
|
|
979
|
+
failed_secrets.append(name)
|
|
980
|
+
else:
|
|
981
|
+
successful_results[name] = result
|
|
982
|
+
|
|
983
|
+
# If there were failures and required=True, raise aggregated error
|
|
984
|
+
if failed_secrets and required:
|
|
985
|
+
context = ModelInfraErrorContext.with_correlation(
|
|
986
|
+
transport_type=EnumInfraTransportType.RUNTIME,
|
|
987
|
+
operation="get_secrets_async",
|
|
988
|
+
target_name="secret_resolver",
|
|
989
|
+
)
|
|
990
|
+
# SECURITY: Log at INFO level with count only (for operational awareness)
|
|
991
|
+
# Secret identifiers are logged at DEBUG level only to prevent exposure
|
|
992
|
+
logger.info(
|
|
993
|
+
"Secret resolution failed for %d secret(s) (correlation_id=%s). "
|
|
994
|
+
"Enable DEBUG logging to see secret names.",
|
|
995
|
+
len(failed_secrets),
|
|
996
|
+
context.correlation_id,
|
|
997
|
+
extra={
|
|
998
|
+
"correlation_id": str(context.correlation_id),
|
|
999
|
+
"failed_count": len(failed_secrets),
|
|
1000
|
+
},
|
|
1001
|
+
)
|
|
1002
|
+
# SECURITY: Log failed secret names at DEBUG level only (with correlation_id)
|
|
1003
|
+
# to avoid exposing secret structure in error messages shown to users/logs
|
|
1004
|
+
logger.debug(
|
|
1005
|
+
"Failed secret names (correlation_id=%s): %s",
|
|
1006
|
+
context.correlation_id,
|
|
1007
|
+
", ".join(failed_secrets),
|
|
1008
|
+
extra={
|
|
1009
|
+
"correlation_id": str(context.correlation_id),
|
|
1010
|
+
"failed_count": len(failed_secrets),
|
|
1011
|
+
},
|
|
1012
|
+
)
|
|
1013
|
+
# SECURITY: Do NOT include logical_name in error - it exposes secret identifiers
|
|
1014
|
+
# Use correlation_id to trace back to DEBUG logs if needed
|
|
1015
|
+
raise SecretResolutionError(
|
|
1016
|
+
f"Failed to resolve {len(failed_secrets)} secret(s). "
|
|
1017
|
+
f"See logs with correlation_id={context.correlation_id} for details.",
|
|
1018
|
+
context=context,
|
|
1019
|
+
# NOTE: Intentionally NOT passing logical_name to avoid exposing
|
|
1020
|
+
# secret identifiers in error messages, logs, or serialized responses.
|
|
1021
|
+
# The correlation_id can be used to find secret names in DEBUG logs.
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
return successful_results
|
|
1025
|
+
|
|
1026
|
+
# === Cache Management ===
|
|
1027
|
+
|
|
1028
|
+
def refresh(self, logical_name: str) -> None:
|
|
1029
|
+
"""Force refresh a single secret (invalidate cache).
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
logical_name: The logical name to refresh
|
|
1033
|
+
"""
|
|
1034
|
+
with self._lock:
|
|
1035
|
+
if logical_name in self._cache:
|
|
1036
|
+
del self._cache[logical_name]
|
|
1037
|
+
if logical_name in self._hit_counts:
|
|
1038
|
+
del self._hit_counts[logical_name]
|
|
1039
|
+
self._refreshes += 1
|
|
1040
|
+
|
|
1041
|
+
def refresh_all(self) -> None:
|
|
1042
|
+
"""Force refresh all cached secrets."""
|
|
1043
|
+
with self._lock:
|
|
1044
|
+
count = len(self._cache)
|
|
1045
|
+
self._cache.clear()
|
|
1046
|
+
self._hit_counts.clear()
|
|
1047
|
+
self._refreshes += count
|
|
1048
|
+
|
|
1049
|
+
def get_cache_stats(self) -> ModelSecretCacheStats:
|
|
1050
|
+
"""Return cache statistics.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
ModelSecretCacheStats with hit/miss/refresh counts
|
|
1054
|
+
"""
|
|
1055
|
+
with self._lock:
|
|
1056
|
+
return ModelSecretCacheStats(
|
|
1057
|
+
total_entries=len(self._cache),
|
|
1058
|
+
hits=self._hits,
|
|
1059
|
+
misses=self._misses,
|
|
1060
|
+
refreshes=self._refreshes,
|
|
1061
|
+
expired_evictions=self._expired_evictions,
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
def get_resolution_metrics(self) -> ModelSecretResolverMetrics:
|
|
1065
|
+
"""Return resolution metrics for observability.
|
|
1066
|
+
|
|
1067
|
+
Returns:
|
|
1068
|
+
ModelSecretResolverMetrics with:
|
|
1069
|
+
- success_counts: Dict of source_type -> success count
|
|
1070
|
+
- failure_counts: Dict of source_type -> failure count
|
|
1071
|
+
- latency_samples: Number of latency samples collected
|
|
1072
|
+
- avg_latency_ms: Average resolution latency (if samples > 0)
|
|
1073
|
+
- cache_hits: Total number of cache hits
|
|
1074
|
+
- cache_misses: Total number of cache misses
|
|
1075
|
+
"""
|
|
1076
|
+
with self._lock:
|
|
1077
|
+
avg_latency = 0.0
|
|
1078
|
+
if self._resolution_latencies:
|
|
1079
|
+
avg_latency = sum(lat for _, lat in self._resolution_latencies) / len(
|
|
1080
|
+
self._resolution_latencies
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
return ModelSecretResolverMetrics(
|
|
1084
|
+
success_counts=dict(self._resolution_success_counts),
|
|
1085
|
+
failure_counts=dict(self._resolution_failure_counts),
|
|
1086
|
+
latency_samples=len(self._resolution_latencies),
|
|
1087
|
+
avg_latency_ms=avg_latency,
|
|
1088
|
+
cache_hits=self._hits,
|
|
1089
|
+
cache_misses=self._misses,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
def set_metrics_collector(
|
|
1093
|
+
self, collector: ProtocolSecretResolverMetrics | None
|
|
1094
|
+
) -> None:
|
|
1095
|
+
"""Set the external metrics collector.
|
|
1096
|
+
|
|
1097
|
+
Thread Safety:
|
|
1098
|
+
This method is thread-safe. The collector reference is updated
|
|
1099
|
+
atomically under ``_lock``. Concurrent calls to resolution methods
|
|
1100
|
+
will see either the old or new collector (never a partial state).
|
|
1101
|
+
|
|
1102
|
+
The pattern used in ``_record_resolution_success`` and
|
|
1103
|
+
``_record_resolution_failure`` captures the collector reference
|
|
1104
|
+
while holding the lock, then uses it outside the lock. This ensures
|
|
1105
|
+
that even if ``set_metrics_collector()`` is called concurrently,
|
|
1106
|
+
each resolution operation uses a consistent collector reference.
|
|
1107
|
+
|
|
1108
|
+
Args:
|
|
1109
|
+
collector: Metrics collector implementing ProtocolSecretResolverMetrics,
|
|
1110
|
+
or None to disable external metrics collection.
|
|
1111
|
+
"""
|
|
1112
|
+
with self._lock:
|
|
1113
|
+
self._metrics_collector = collector
|
|
1114
|
+
|
|
1115
|
+
def _record_resolution_success(
|
|
1116
|
+
self,
|
|
1117
|
+
logical_name: str,
|
|
1118
|
+
source_type: str,
|
|
1119
|
+
correlation_id: UUID,
|
|
1120
|
+
start_time: float | None = None,
|
|
1121
|
+
) -> None:
|
|
1122
|
+
"""Record a successful secret resolution.
|
|
1123
|
+
|
|
1124
|
+
Thread Safety:
|
|
1125
|
+
Captures ``_metrics_collector`` reference while holding ``_lock`` to
|
|
1126
|
+
prevent race conditions with concurrent ``set_metrics_collector()``
|
|
1127
|
+
calls. The captured reference is then used outside the lock to avoid
|
|
1128
|
+
holding the lock during potentially slow I/O operations.
|
|
1129
|
+
|
|
1130
|
+
Args:
|
|
1131
|
+
logical_name: The secret's logical name
|
|
1132
|
+
source_type: Source type (env, file, vault, cache)
|
|
1133
|
+
correlation_id: Correlation ID for tracing
|
|
1134
|
+
start_time: Optional start time from time.monotonic() for latency calc
|
|
1135
|
+
"""
|
|
1136
|
+
latency_ms = 0.0
|
|
1137
|
+
if start_time is not None:
|
|
1138
|
+
latency_ms = (time.monotonic() - start_time) * 1000
|
|
1139
|
+
|
|
1140
|
+
# Internal tracking + capture collector reference atomically
|
|
1141
|
+
with self._lock:
|
|
1142
|
+
self._resolution_success_counts[source_type] += 1
|
|
1143
|
+
if start_time is not None:
|
|
1144
|
+
# deque with maxlen=1000 automatically rotates (O(1) vs O(n) for list.pop(0))
|
|
1145
|
+
self._resolution_latencies.append((source_type, latency_ms))
|
|
1146
|
+
# THREAD SAFETY: Capture collector reference while holding lock to prevent
|
|
1147
|
+
# race with set_metrics_collector(). Use captured ref outside lock.
|
|
1148
|
+
collector = self._metrics_collector
|
|
1149
|
+
|
|
1150
|
+
# External metrics collector - use captured reference (may be None)
|
|
1151
|
+
if collector is not None:
|
|
1152
|
+
try:
|
|
1153
|
+
if hasattr(collector, "record_resolution_success"):
|
|
1154
|
+
collector.record_resolution_success(source_type)
|
|
1155
|
+
if start_time is not None and hasattr(
|
|
1156
|
+
collector, "record_resolution_latency"
|
|
1157
|
+
):
|
|
1158
|
+
collector.record_resolution_latency(source_type, latency_ms)
|
|
1159
|
+
if source_type == "cache" and hasattr(collector, "record_cache_hit"):
|
|
1160
|
+
collector.record_cache_hit()
|
|
1161
|
+
except Exception as e:
|
|
1162
|
+
# Never let metrics failures affect secret resolution, but log
|
|
1163
|
+
# at warning level since a configured collector failing indicates
|
|
1164
|
+
# an integration issue worth investigating.
|
|
1165
|
+
logger.warning(
|
|
1166
|
+
"Metrics collector error (ignored, resolution unaffected): %s",
|
|
1167
|
+
e,
|
|
1168
|
+
extra={
|
|
1169
|
+
"logical_name": logical_name,
|
|
1170
|
+
"correlation_id": str(correlation_id),
|
|
1171
|
+
"exception_type": type(e).__name__,
|
|
1172
|
+
},
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
# Structured logging
|
|
1176
|
+
logger.debug(
|
|
1177
|
+
"Secret resolved successfully: %s",
|
|
1178
|
+
logical_name,
|
|
1179
|
+
extra={
|
|
1180
|
+
"logical_name": logical_name,
|
|
1181
|
+
"source_type": source_type,
|
|
1182
|
+
"cache_hit": source_type == "cache",
|
|
1183
|
+
"latency_ms": latency_ms,
|
|
1184
|
+
"correlation_id": str(correlation_id),
|
|
1185
|
+
},
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
def _record_resolution_failure(
|
|
1189
|
+
self,
|
|
1190
|
+
logical_name: str,
|
|
1191
|
+
source_type: str,
|
|
1192
|
+
correlation_id: UUID,
|
|
1193
|
+
reason: str,
|
|
1194
|
+
) -> None:
|
|
1195
|
+
"""Record a failed secret resolution.
|
|
1196
|
+
|
|
1197
|
+
Thread Safety:
|
|
1198
|
+
Captures ``_metrics_collector`` reference while holding ``_lock`` to
|
|
1199
|
+
prevent race conditions with concurrent ``set_metrics_collector()``
|
|
1200
|
+
calls. The captured reference is then used outside the lock to avoid
|
|
1201
|
+
holding the lock during potentially slow I/O operations.
|
|
1202
|
+
|
|
1203
|
+
Args:
|
|
1204
|
+
logical_name: The secret's logical name
|
|
1205
|
+
source_type: Source type (env, file, vault, unknown)
|
|
1206
|
+
correlation_id: Correlation ID for tracing
|
|
1207
|
+
reason: Failure reason (not_found, handler_not_configured, etc.)
|
|
1208
|
+
"""
|
|
1209
|
+
# Internal tracking + capture collector reference atomically
|
|
1210
|
+
with self._lock:
|
|
1211
|
+
self._resolution_failure_counts[source_type] += 1
|
|
1212
|
+
# THREAD SAFETY: Capture collector reference while holding lock to prevent
|
|
1213
|
+
# race with set_metrics_collector(). Use captured ref outside lock.
|
|
1214
|
+
collector = self._metrics_collector
|
|
1215
|
+
|
|
1216
|
+
# External metrics collector - use captured reference (may be None)
|
|
1217
|
+
if collector is not None:
|
|
1218
|
+
try:
|
|
1219
|
+
if hasattr(collector, "record_resolution_failure"):
|
|
1220
|
+
collector.record_resolution_failure(source_type)
|
|
1221
|
+
# NOTE: Do NOT call record_cache_miss() here - resolution failures
|
|
1222
|
+
# are distinct from cache misses. Cache misses are already tracked
|
|
1223
|
+
# in get_secret() and get_secret_async() via self._misses += 1
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
# Never let metrics failures affect secret resolution, but log
|
|
1226
|
+
# at warning level since a configured collector failing indicates
|
|
1227
|
+
# an integration issue worth investigating.
|
|
1228
|
+
logger.warning(
|
|
1229
|
+
"Metrics collector error (ignored, resolution unaffected): %s",
|
|
1230
|
+
e,
|
|
1231
|
+
extra={
|
|
1232
|
+
"logical_name": logical_name,
|
|
1233
|
+
"correlation_id": str(correlation_id),
|
|
1234
|
+
"exception_type": type(e).__name__,
|
|
1235
|
+
},
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
# Structured logging - level depends on failure type
|
|
1239
|
+
# Configuration issues (no_mapping, handler_not_configured) are warnings
|
|
1240
|
+
# since they indicate misconfiguration that should be addressed.
|
|
1241
|
+
# Expected failures (not_found for optional secrets) are debug level.
|
|
1242
|
+
if reason in ("no_mapping", "handler_not_configured"):
|
|
1243
|
+
logger.warning(
|
|
1244
|
+
"Secret resolution failed (configuration issue): %s",
|
|
1245
|
+
logical_name,
|
|
1246
|
+
extra={
|
|
1247
|
+
"logical_name": logical_name,
|
|
1248
|
+
"source_type": source_type,
|
|
1249
|
+
"reason": reason,
|
|
1250
|
+
"correlation_id": str(correlation_id),
|
|
1251
|
+
},
|
|
1252
|
+
)
|
|
1253
|
+
else:
|
|
1254
|
+
# not_found and other expected failures - debug level
|
|
1255
|
+
logger.debug(
|
|
1256
|
+
"Secret resolution failed: %s",
|
|
1257
|
+
logical_name,
|
|
1258
|
+
extra={
|
|
1259
|
+
"logical_name": logical_name,
|
|
1260
|
+
"source_type": source_type,
|
|
1261
|
+
"reason": reason,
|
|
1262
|
+
"correlation_id": str(correlation_id),
|
|
1263
|
+
},
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# === Introspection (non-sensitive) ===
|
|
1267
|
+
|
|
1268
|
+
def list_configured_secrets(self) -> list[str]:
|
|
1269
|
+
"""List all configured logical names (not values).
|
|
1270
|
+
|
|
1271
|
+
Returns:
|
|
1272
|
+
List of logical names from configuration
|
|
1273
|
+
"""
|
|
1274
|
+
return list(self._mappings.keys())
|
|
1275
|
+
|
|
1276
|
+
def get_source_info(self, logical_name: str) -> ModelSecretSourceInfo | None:
|
|
1277
|
+
"""Return source type and masked path for a logical name.
|
|
1278
|
+
|
|
1279
|
+
This method is safe to use for debugging and monitoring as it
|
|
1280
|
+
never exposes actual secret values.
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
logical_name: The logical name to inspect
|
|
1284
|
+
|
|
1285
|
+
Returns:
|
|
1286
|
+
ModelSecretSourceInfo with masked path, or None if not configured
|
|
1287
|
+
"""
|
|
1288
|
+
source = self._get_source_spec(logical_name)
|
|
1289
|
+
if source is None:
|
|
1290
|
+
return None
|
|
1291
|
+
|
|
1292
|
+
# Mask sensitive parts of the path
|
|
1293
|
+
masked_path = self._mask_source_path(source)
|
|
1294
|
+
|
|
1295
|
+
# Use lock for thread-safe cache access
|
|
1296
|
+
with self._lock:
|
|
1297
|
+
cached_entry = self._cache.get(logical_name)
|
|
1298
|
+
return ModelSecretSourceInfo(
|
|
1299
|
+
logical_name=logical_name,
|
|
1300
|
+
source_type=source.source_type,
|
|
1301
|
+
source_path_masked=masked_path,
|
|
1302
|
+
is_cached=cached_entry is not None,
|
|
1303
|
+
expires_at=cached_entry.expires_at if cached_entry else None,
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
# === Internal Methods ===
|
|
1307
|
+
|
|
1308
|
+
def _get_from_cache(self, logical_name: str) -> SecretStr | None:
|
|
1309
|
+
"""Get secret from cache if present and not expired.
|
|
1310
|
+
|
|
1311
|
+
Args:
|
|
1312
|
+
logical_name: The logical name to look up
|
|
1313
|
+
|
|
1314
|
+
Returns:
|
|
1315
|
+
SecretStr if cached and valid, None otherwise
|
|
1316
|
+
"""
|
|
1317
|
+
cached = self._cache.get(logical_name)
|
|
1318
|
+
if cached is None:
|
|
1319
|
+
return None
|
|
1320
|
+
|
|
1321
|
+
if cached.is_expired():
|
|
1322
|
+
del self._cache[logical_name]
|
|
1323
|
+
self._hit_counts.pop(logical_name, None)
|
|
1324
|
+
self._expired_evictions += 1
|
|
1325
|
+
return None
|
|
1326
|
+
|
|
1327
|
+
# Track hits using internal counter (model is frozen)
|
|
1328
|
+
self._hit_counts[logical_name] += 1
|
|
1329
|
+
self._hits += 1
|
|
1330
|
+
return cached.value
|
|
1331
|
+
|
|
1332
|
+
def _is_bootstrap_secret(self, logical_name: str) -> bool:
|
|
1333
|
+
"""Check if a logical name is a bootstrap secret.
|
|
1334
|
+
|
|
1335
|
+
Bootstrap secrets are resolved ONLY from environment variables, never from
|
|
1336
|
+
Vault or files. This ensures they're available before Vault is initialized.
|
|
1337
|
+
|
|
1338
|
+
Security:
|
|
1339
|
+
Bootstrap secrets (vault.token, vault.addr, vault.ca_cert) are needed
|
|
1340
|
+
to initialize the Vault connection. They MUST come from env vars to
|
|
1341
|
+
avoid a circular dependency.
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
logical_name: The logical name to check
|
|
1345
|
+
|
|
1346
|
+
Returns:
|
|
1347
|
+
True if this is a bootstrap secret that bypasses normal resolution
|
|
1348
|
+
"""
|
|
1349
|
+
return logical_name in self._config.bootstrap_secrets
|
|
1350
|
+
|
|
1351
|
+
def _resolve_bootstrap_secret_value(self, logical_name: str) -> SecretStr | None:
|
|
1352
|
+
"""Resolve a bootstrap secret value from environment variables.
|
|
1353
|
+
|
|
1354
|
+
Thread Safety:
|
|
1355
|
+
This method only reads from environment variables (atomic on most platforms)
|
|
1356
|
+
and does NOT write to cache. The caller is responsible for cache writes
|
|
1357
|
+
with proper locking.
|
|
1358
|
+
|
|
1359
|
+
Security:
|
|
1360
|
+
Bootstrap secrets are isolated from the normal resolution chain.
|
|
1361
|
+
They are ALWAYS resolved from environment variables only (never vault/file).
|
|
1362
|
+
If an explicit mapping exists for an env source, that mapping is honored.
|
|
1363
|
+
Otherwise, convention-based naming (logical_name -> ENV_VAR) is used.
|
|
1364
|
+
|
|
1365
|
+
Args:
|
|
1366
|
+
logical_name: The bootstrap secret's logical name
|
|
1367
|
+
|
|
1368
|
+
Returns:
|
|
1369
|
+
SecretStr if found, None if env var is not set
|
|
1370
|
+
"""
|
|
1371
|
+
# First, check for explicit env var mapping (same priority as normal secrets)
|
|
1372
|
+
# This ensures that explicit mappings like:
|
|
1373
|
+
# {"vault.token": ModelSecretSourceSpec(source_type="env", source_path="MY_VAULT_TOKEN")}
|
|
1374
|
+
# are respected for bootstrap secrets.
|
|
1375
|
+
if logical_name in self._mappings:
|
|
1376
|
+
mapping = self._mappings[logical_name]
|
|
1377
|
+
if mapping.source_type == "env":
|
|
1378
|
+
# Use the explicitly mapped env var name
|
|
1379
|
+
env_var = mapping.source_path
|
|
1380
|
+
else:
|
|
1381
|
+
# Non-env mappings (vault/file) are invalid for bootstrap secrets
|
|
1382
|
+
# by design - they must come from env to avoid circular dependency.
|
|
1383
|
+
# Fall back to convention for the env var name.
|
|
1384
|
+
env_var = self._logical_name_to_env_var(logical_name)
|
|
1385
|
+
else:
|
|
1386
|
+
# No explicit mapping - use convention fallback
|
|
1387
|
+
env_var = self._logical_name_to_env_var(logical_name)
|
|
1388
|
+
|
|
1389
|
+
value = os.environ.get(env_var)
|
|
1390
|
+
|
|
1391
|
+
if value is None:
|
|
1392
|
+
return None
|
|
1393
|
+
|
|
1394
|
+
return SecretStr(value)
|
|
1395
|
+
|
|
1396
|
+
def _resolve_secret(
|
|
1397
|
+
self, logical_name: str, correlation_id: UUID | None = None
|
|
1398
|
+
) -> SecretStr | None:
|
|
1399
|
+
"""Resolve secret from source and cache it.
|
|
1400
|
+
|
|
1401
|
+
Thread Safety:
|
|
1402
|
+
This method MUST be called while holding _lock. It writes to cache
|
|
1403
|
+
directly without additional locking.
|
|
1404
|
+
|
|
1405
|
+
Security:
|
|
1406
|
+
Bootstrap secrets (vault.token, vault.addr, etc.) are resolved directly
|
|
1407
|
+
from environment variables, bypassing the normal source chain. This
|
|
1408
|
+
prevents circular dependencies when initializing Vault.
|
|
1409
|
+
|
|
1410
|
+
Args:
|
|
1411
|
+
logical_name: The logical name to resolve
|
|
1412
|
+
correlation_id: Optional correlation ID for tracing
|
|
1413
|
+
|
|
1414
|
+
Returns:
|
|
1415
|
+
SecretStr if found, None otherwise
|
|
1416
|
+
"""
|
|
1417
|
+
effective_correlation_id = correlation_id or uuid4()
|
|
1418
|
+
start_time = time.monotonic()
|
|
1419
|
+
|
|
1420
|
+
# SECURITY: Bootstrap secrets bypass normal resolution
|
|
1421
|
+
# They must come from env vars to avoid circular dependency with Vault
|
|
1422
|
+
if self._is_bootstrap_secret(logical_name):
|
|
1423
|
+
secret = self._resolve_bootstrap_secret_value(logical_name)
|
|
1424
|
+
if secret is not None:
|
|
1425
|
+
# Cache write is safe here - caller holds _lock
|
|
1426
|
+
self._cache_secret(logical_name, secret, "env")
|
|
1427
|
+
self._record_resolution_success(
|
|
1428
|
+
logical_name, "env", effective_correlation_id, start_time
|
|
1429
|
+
)
|
|
1430
|
+
return secret
|
|
1431
|
+
|
|
1432
|
+
source = self._get_source_spec(logical_name)
|
|
1433
|
+
if source is None:
|
|
1434
|
+
self._record_resolution_failure(
|
|
1435
|
+
logical_name, "unknown", effective_correlation_id, "no_mapping"
|
|
1436
|
+
)
|
|
1437
|
+
return None
|
|
1438
|
+
|
|
1439
|
+
value: str | None = None
|
|
1440
|
+
|
|
1441
|
+
if source.source_type == "env":
|
|
1442
|
+
value = os.environ.get(source.source_path)
|
|
1443
|
+
elif source.source_type == "file":
|
|
1444
|
+
value = self._read_file_secret(source.source_path, logical_name)
|
|
1445
|
+
elif source.source_type == "vault":
|
|
1446
|
+
if self._vault_handler is None:
|
|
1447
|
+
logger.warning(
|
|
1448
|
+
"Vault handler not configured for secret: %s",
|
|
1449
|
+
logical_name,
|
|
1450
|
+
extra={
|
|
1451
|
+
"logical_name": logical_name,
|
|
1452
|
+
"correlation_id": str(effective_correlation_id),
|
|
1453
|
+
},
|
|
1454
|
+
)
|
|
1455
|
+
self._record_resolution_failure(
|
|
1456
|
+
logical_name,
|
|
1457
|
+
"vault",
|
|
1458
|
+
effective_correlation_id,
|
|
1459
|
+
"handler_not_configured",
|
|
1460
|
+
)
|
|
1461
|
+
return None
|
|
1462
|
+
value = self._read_vault_secret_sync(
|
|
1463
|
+
source.source_path, logical_name, effective_correlation_id
|
|
1464
|
+
)
|
|
1465
|
+
|
|
1466
|
+
if value is None:
|
|
1467
|
+
self._record_resolution_failure(
|
|
1468
|
+
logical_name, source.source_type, effective_correlation_id, "not_found"
|
|
1469
|
+
)
|
|
1470
|
+
return None
|
|
1471
|
+
|
|
1472
|
+
secret = SecretStr(value)
|
|
1473
|
+
self._cache_secret(logical_name, secret, source.source_type)
|
|
1474
|
+
self._record_resolution_success(
|
|
1475
|
+
logical_name, source.source_type, effective_correlation_id, start_time
|
|
1476
|
+
)
|
|
1477
|
+
return secret
|
|
1478
|
+
|
|
1479
|
+
async def _resolve_secret_async(
|
|
1480
|
+
self, logical_name: str, correlation_id: UUID | None = None
|
|
1481
|
+
) -> SecretStr | None:
|
|
1482
|
+
"""Resolve secret from source asynchronously.
|
|
1483
|
+
|
|
1484
|
+
Thread Safety:
|
|
1485
|
+
Uses threading.RLock for cache writes to prevent race conditions
|
|
1486
|
+
with sync callers. I/O operations are performed outside the lock.
|
|
1487
|
+
Bootstrap secrets also use _lock for their cache writes to ensure
|
|
1488
|
+
thread-safe access from both sync and async contexts.
|
|
1489
|
+
|
|
1490
|
+
Security:
|
|
1491
|
+
Bootstrap secrets (vault.token, vault.addr, etc.) are resolved directly
|
|
1492
|
+
from environment variables, bypassing the normal source chain. This
|
|
1493
|
+
prevents circular dependencies when initializing Vault.
|
|
1494
|
+
|
|
1495
|
+
Args:
|
|
1496
|
+
logical_name: The logical name to resolve
|
|
1497
|
+
correlation_id: Optional correlation ID for tracing
|
|
1498
|
+
|
|
1499
|
+
Returns:
|
|
1500
|
+
SecretStr if found, None otherwise
|
|
1501
|
+
"""
|
|
1502
|
+
effective_correlation_id = correlation_id or uuid4()
|
|
1503
|
+
start_time = time.monotonic()
|
|
1504
|
+
|
|
1505
|
+
# SECURITY: Bootstrap secrets bypass normal resolution
|
|
1506
|
+
# They must come from env vars to avoid circular dependency with Vault
|
|
1507
|
+
if self._is_bootstrap_secret(logical_name):
|
|
1508
|
+
# Resolve value (no cache write in _resolve_bootstrap_secret_value)
|
|
1509
|
+
secret = self._resolve_bootstrap_secret_value(logical_name)
|
|
1510
|
+
if secret is not None:
|
|
1511
|
+
# THREAD SAFETY: Use lock for cache write to prevent race with sync callers
|
|
1512
|
+
# Check-before-write pattern avoids unnecessary overwrites if sync caller
|
|
1513
|
+
# already cached this secret between our cache check and now
|
|
1514
|
+
with self._lock:
|
|
1515
|
+
if logical_name not in self._cache:
|
|
1516
|
+
self._cache_secret(logical_name, secret, "env")
|
|
1517
|
+
self._record_resolution_success(
|
|
1518
|
+
logical_name, "env", effective_correlation_id, start_time
|
|
1519
|
+
)
|
|
1520
|
+
return secret
|
|
1521
|
+
|
|
1522
|
+
source = self._get_source_spec(logical_name)
|
|
1523
|
+
if source is None:
|
|
1524
|
+
self._record_resolution_failure(
|
|
1525
|
+
logical_name, "unknown", effective_correlation_id, "no_mapping"
|
|
1526
|
+
)
|
|
1527
|
+
return None
|
|
1528
|
+
|
|
1529
|
+
value: str | None = None
|
|
1530
|
+
|
|
1531
|
+
# I/O operations - NOT under lock to avoid blocking
|
|
1532
|
+
if source.source_type == "env":
|
|
1533
|
+
value = os.environ.get(source.source_path)
|
|
1534
|
+
elif source.source_type == "file":
|
|
1535
|
+
value = await asyncio.to_thread(
|
|
1536
|
+
self._read_file_secret, source.source_path, logical_name
|
|
1537
|
+
)
|
|
1538
|
+
elif source.source_type == "vault":
|
|
1539
|
+
if self._vault_handler is None:
|
|
1540
|
+
logger.warning(
|
|
1541
|
+
"Vault handler not configured for secret: %s",
|
|
1542
|
+
logical_name,
|
|
1543
|
+
extra={
|
|
1544
|
+
"logical_name": logical_name,
|
|
1545
|
+
"correlation_id": str(effective_correlation_id),
|
|
1546
|
+
},
|
|
1547
|
+
)
|
|
1548
|
+
self._record_resolution_failure(
|
|
1549
|
+
logical_name,
|
|
1550
|
+
"vault",
|
|
1551
|
+
effective_correlation_id,
|
|
1552
|
+
"handler_not_configured",
|
|
1553
|
+
)
|
|
1554
|
+
return None
|
|
1555
|
+
value = await self._read_vault_secret_async(
|
|
1556
|
+
source.source_path, logical_name, effective_correlation_id
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
if value is None:
|
|
1560
|
+
self._record_resolution_failure(
|
|
1561
|
+
logical_name, source.source_type, effective_correlation_id, "not_found"
|
|
1562
|
+
)
|
|
1563
|
+
return None
|
|
1564
|
+
|
|
1565
|
+
secret = SecretStr(value)
|
|
1566
|
+
# THREAD SAFETY: Use lock for cache write to prevent race with sync callers
|
|
1567
|
+
# Check-before-write pattern avoids unnecessary overwrites if sync caller
|
|
1568
|
+
# already cached this secret between our cache check and now
|
|
1569
|
+
with self._lock:
|
|
1570
|
+
if logical_name not in self._cache:
|
|
1571
|
+
self._cache_secret(logical_name, secret, source.source_type)
|
|
1572
|
+
self._record_resolution_success(
|
|
1573
|
+
logical_name, source.source_type, effective_correlation_id, start_time
|
|
1574
|
+
)
|
|
1575
|
+
return secret
|
|
1576
|
+
|
|
1577
|
+
def _get_source_spec(self, logical_name: str) -> ModelSecretSourceSpec | None:
|
|
1578
|
+
"""Get source spec from mapping or convention fallback.
|
|
1579
|
+
|
|
1580
|
+
Args:
|
|
1581
|
+
logical_name: The logical name to look up
|
|
1582
|
+
|
|
1583
|
+
Returns:
|
|
1584
|
+
ModelSecretSourceSpec if found, None otherwise
|
|
1585
|
+
"""
|
|
1586
|
+
# Try explicit mapping first
|
|
1587
|
+
if logical_name in self._mappings:
|
|
1588
|
+
return self._mappings[logical_name]
|
|
1589
|
+
|
|
1590
|
+
# Try convention fallback
|
|
1591
|
+
if self._config.enable_convention_fallback:
|
|
1592
|
+
env_var = self._logical_name_to_env_var(logical_name)
|
|
1593
|
+
return ModelSecretSourceSpec(source_type="env", source_path=env_var)
|
|
1594
|
+
|
|
1595
|
+
return None
|
|
1596
|
+
|
|
1597
|
+
def _logical_name_to_env_var(self, logical_name: str) -> str:
|
|
1598
|
+
"""Convert dotted logical name to environment variable name.
|
|
1599
|
+
|
|
1600
|
+
Example:
|
|
1601
|
+
"database.postgres.password" -> "DATABASE_POSTGRES_PASSWORD"
|
|
1602
|
+
With prefix "ONEX_": "database.postgres.password" -> "ONEX_DATABASE_POSTGRES_PASSWORD"
|
|
1603
|
+
|
|
1604
|
+
Args:
|
|
1605
|
+
logical_name: Dotted path to convert
|
|
1606
|
+
|
|
1607
|
+
Returns:
|
|
1608
|
+
Environment variable name
|
|
1609
|
+
"""
|
|
1610
|
+
env_var = logical_name.upper().replace(".", "_")
|
|
1611
|
+
if self._config.convention_env_prefix:
|
|
1612
|
+
env_var = f"{self._config.convention_env_prefix}{env_var}"
|
|
1613
|
+
return env_var
|
|
1614
|
+
|
|
1615
|
+
def _read_file_secret(self, path: str, logical_name: str = "") -> str | None:
|
|
1616
|
+
"""Read secret from file.
|
|
1617
|
+
|
|
1618
|
+
Thread Safety:
|
|
1619
|
+
This method avoids TOCTOU race conditions by catching exceptions
|
|
1620
|
+
during the read operation rather than pre-checking file existence.
|
|
1621
|
+
|
|
1622
|
+
Security:
|
|
1623
|
+
- Path traversal attacks are prevented by validating resolved paths
|
|
1624
|
+
stay within the configured secrets_dir
|
|
1625
|
+
- Error messages are sanitized to avoid leaking path information
|
|
1626
|
+
- No secret values are ever logged
|
|
1627
|
+
|
|
1628
|
+
Args:
|
|
1629
|
+
path: Path to the secret file (absolute or relative to secrets_dir)
|
|
1630
|
+
logical_name: The logical name being resolved (for error context only)
|
|
1631
|
+
|
|
1632
|
+
Returns:
|
|
1633
|
+
Secret value with whitespace stripped, or None if not found or unreadable
|
|
1634
|
+
"""
|
|
1635
|
+
secret_path = Path(path)
|
|
1636
|
+
|
|
1637
|
+
# Track whether the original path was relative BEFORE combining with secrets_dir
|
|
1638
|
+
# This is critical for path traversal detection
|
|
1639
|
+
original_is_relative = not secret_path.is_absolute()
|
|
1640
|
+
|
|
1641
|
+
# If relative path, resolve against secrets_dir
|
|
1642
|
+
if original_is_relative:
|
|
1643
|
+
secret_path = self._config.secrets_dir / path
|
|
1644
|
+
|
|
1645
|
+
# Resolve to absolute path to detect path traversal
|
|
1646
|
+
try:
|
|
1647
|
+
resolved_path = secret_path.resolve()
|
|
1648
|
+
except (OSError, RuntimeError):
|
|
1649
|
+
# resolve() can fail on invalid paths or symlink loops
|
|
1650
|
+
logger.warning(
|
|
1651
|
+
"Invalid secret path for logical name: %s",
|
|
1652
|
+
logical_name,
|
|
1653
|
+
extra={"logical_name": logical_name},
|
|
1654
|
+
)
|
|
1655
|
+
return None
|
|
1656
|
+
|
|
1657
|
+
# SECURITY: Prevent path traversal attacks
|
|
1658
|
+
# Verify the resolved path is within secrets_dir for relative paths
|
|
1659
|
+
# Absolute paths are trusted (explicitly configured by administrator)
|
|
1660
|
+
if original_is_relative:
|
|
1661
|
+
secrets_dir_resolved = self._config.secrets_dir.resolve()
|
|
1662
|
+
# Relative paths MUST resolve within secrets_dir
|
|
1663
|
+
# Use is_relative_to() to check without raising (Python 3.9+)
|
|
1664
|
+
if not resolved_path.is_relative_to(secrets_dir_resolved):
|
|
1665
|
+
# Path escapes secrets_dir - this is a path traversal attempt
|
|
1666
|
+
# SECURITY: Log at ERROR level - potential attack indicator
|
|
1667
|
+
logger.error(
|
|
1668
|
+
"Path traversal detected for secret: %s",
|
|
1669
|
+
logical_name,
|
|
1670
|
+
extra={"logical_name": logical_name},
|
|
1671
|
+
)
|
|
1672
|
+
return None
|
|
1673
|
+
|
|
1674
|
+
# Avoid TOCTOU race: read atomically with size limit instead of stat() then read()
|
|
1675
|
+
# This prevents an attacker from swapping the file between size check and read
|
|
1676
|
+
try:
|
|
1677
|
+
# Read up to MAX_SECRET_FILE_SIZE + 1 bytes atomically
|
|
1678
|
+
# If we got more than MAX_SECRET_FILE_SIZE, the file is too large
|
|
1679
|
+
with resolved_path.open("r") as f:
|
|
1680
|
+
content = f.read(MAX_SECRET_FILE_SIZE + 1)
|
|
1681
|
+
if len(content) > MAX_SECRET_FILE_SIZE:
|
|
1682
|
+
logger.warning(
|
|
1683
|
+
"Secret file exceeds size limit: %s",
|
|
1684
|
+
logical_name,
|
|
1685
|
+
extra={"logical_name": logical_name},
|
|
1686
|
+
)
|
|
1687
|
+
return None
|
|
1688
|
+
return content.strip()
|
|
1689
|
+
except FileNotFoundError:
|
|
1690
|
+
# File does not exist - this is expected for optional secrets
|
|
1691
|
+
# SECURITY: Don't log the actual path to avoid information disclosure
|
|
1692
|
+
logger.debug(
|
|
1693
|
+
"Secret file not found for logical name: %s",
|
|
1694
|
+
logical_name,
|
|
1695
|
+
extra={"logical_name": logical_name},
|
|
1696
|
+
)
|
|
1697
|
+
return None
|
|
1698
|
+
except IsADirectoryError:
|
|
1699
|
+
# Path exists but is a directory, not a file
|
|
1700
|
+
# SECURITY: Don't log the actual path
|
|
1701
|
+
logger.warning(
|
|
1702
|
+
"Secret path is a directory for logical name: %s",
|
|
1703
|
+
logical_name,
|
|
1704
|
+
extra={"logical_name": logical_name},
|
|
1705
|
+
)
|
|
1706
|
+
return None
|
|
1707
|
+
except PermissionError:
|
|
1708
|
+
# Permission denied - log at warning level since this may indicate
|
|
1709
|
+
# a configuration issue (file exists but is not readable)
|
|
1710
|
+
# SECURITY: Don't log the actual path
|
|
1711
|
+
logger.warning(
|
|
1712
|
+
"Permission denied reading secret for logical name: %s",
|
|
1713
|
+
logical_name,
|
|
1714
|
+
extra={"logical_name": logical_name},
|
|
1715
|
+
)
|
|
1716
|
+
return None
|
|
1717
|
+
except OSError as e:
|
|
1718
|
+
# Catch other OS-level errors (e.g., too many open files, I/O errors)
|
|
1719
|
+
# SECURITY: Don't log the path or detailed OS error which may leak info
|
|
1720
|
+
logger.warning(
|
|
1721
|
+
"OS error reading secret for logical name: %s (error type: %s)",
|
|
1722
|
+
logical_name,
|
|
1723
|
+
type(e).__name__,
|
|
1724
|
+
extra={"logical_name": logical_name, "error_type": type(e).__name__},
|
|
1725
|
+
)
|
|
1726
|
+
return None
|
|
1727
|
+
|
|
1728
|
+
def _read_vault_secret_sync(
|
|
1729
|
+
self, path: str, logical_name: str = "", correlation_id: UUID | None = None
|
|
1730
|
+
) -> str | None:
|
|
1731
|
+
"""Read secret from Vault synchronously.
|
|
1732
|
+
|
|
1733
|
+
This method wraps the async Vault handler for synchronous contexts.
|
|
1734
|
+
It creates a new event loop if one is not running, otherwise raises
|
|
1735
|
+
an error (cannot nest event loops).
|
|
1736
|
+
|
|
1737
|
+
Path format: "mount/path#field" or "mount/path" (returns first field value)
|
|
1738
|
+
|
|
1739
|
+
Security:
|
|
1740
|
+
- This method never logs Vault paths (could reveal secret structure)
|
|
1741
|
+
- Secret values are never logged at any level
|
|
1742
|
+
- Error messages are sanitized to include only logical names
|
|
1743
|
+
|
|
1744
|
+
Args:
|
|
1745
|
+
path: Vault path with optional field specifier
|
|
1746
|
+
logical_name: The logical name being resolved (for error context only)
|
|
1747
|
+
correlation_id: Optional correlation ID for tracing
|
|
1748
|
+
|
|
1749
|
+
Returns:
|
|
1750
|
+
Secret value or None if not found
|
|
1751
|
+
|
|
1752
|
+
Raises:
|
|
1753
|
+
SecretResolutionError: On Vault communication failures or if called
|
|
1754
|
+
from within an async context (cannot nest event loops)
|
|
1755
|
+
InfraAuthenticationError: If authentication fails
|
|
1756
|
+
InfraTimeoutError: If the request times out
|
|
1757
|
+
InfraUnavailableError: If Vault is unavailable (circuit breaker open)
|
|
1758
|
+
"""
|
|
1759
|
+
if self._vault_handler is None:
|
|
1760
|
+
return None
|
|
1761
|
+
|
|
1762
|
+
effective_correlation_id = correlation_id or uuid4()
|
|
1763
|
+
|
|
1764
|
+
# Check if we're already in an async context
|
|
1765
|
+
try:
|
|
1766
|
+
asyncio.get_running_loop()
|
|
1767
|
+
# We're in an async context - cannot use asyncio.run()
|
|
1768
|
+
context = ModelInfraErrorContext.with_correlation(
|
|
1769
|
+
correlation_id=effective_correlation_id,
|
|
1770
|
+
transport_type=EnumInfraTransportType.VAULT,
|
|
1771
|
+
operation="read_secret_sync",
|
|
1772
|
+
target_name="secret_resolver",
|
|
1773
|
+
)
|
|
1774
|
+
raise SecretResolutionError(
|
|
1775
|
+
f"Cannot resolve Vault secret synchronously from async context: "
|
|
1776
|
+
f"{logical_name}. Use get_secret_async() instead.",
|
|
1777
|
+
context=context,
|
|
1778
|
+
logical_name=logical_name,
|
|
1779
|
+
)
|
|
1780
|
+
except RuntimeError:
|
|
1781
|
+
# No running event loop - safe to use asyncio.run()
|
|
1782
|
+
pass
|
|
1783
|
+
|
|
1784
|
+
# Run the async method in a new event loop
|
|
1785
|
+
return asyncio.run(
|
|
1786
|
+
self._read_vault_secret_async(path, logical_name, effective_correlation_id)
|
|
1787
|
+
)
|
|
1788
|
+
|
|
1789
|
+
async def _read_vault_secret_async(
|
|
1790
|
+
self, path: str, logical_name: str = "", correlation_id: UUID | None = None
|
|
1791
|
+
) -> str | None:
|
|
1792
|
+
"""Read secret from Vault asynchronously.
|
|
1793
|
+
|
|
1794
|
+
Path format: "mount/path#field" or "mount/path" (returns first field value)
|
|
1795
|
+
|
|
1796
|
+
Examples:
|
|
1797
|
+
"secret/myapp/db#password" -> mount="secret", path="myapp/db", field="password"
|
|
1798
|
+
"secret/myapp/db" -> mount="secret", path="myapp/db", field=None (first value)
|
|
1799
|
+
|
|
1800
|
+
Type Handling:
|
|
1801
|
+
All Vault values are converted to strings via ``str()``. This is intentional
|
|
1802
|
+
because SecretResolver returns ``SecretStr`` values, which only wrap strings.
|
|
1803
|
+
Non-string Vault values (integers, booleans, dicts) are converted as follows:
|
|
1804
|
+
|
|
1805
|
+
- Integers: ``123`` -> ``"123"``
|
|
1806
|
+
- Booleans: ``True`` -> ``"True"``
|
|
1807
|
+
- Lists/Dicts: Python repr (NOT JSON) - avoid storing complex types
|
|
1808
|
+
|
|
1809
|
+
Best Practice: Store secrets as strings in Vault. If you need structured
|
|
1810
|
+
data, store it as a JSON string and parse after resolution.
|
|
1811
|
+
|
|
1812
|
+
Security:
|
|
1813
|
+
- This method never logs Vault paths (could reveal secret structure)
|
|
1814
|
+
- Secret values are never logged at any level
|
|
1815
|
+
- Error messages are sanitized to include only logical names
|
|
1816
|
+
|
|
1817
|
+
Args:
|
|
1818
|
+
path: Vault path with optional field specifier (mount/path#field)
|
|
1819
|
+
logical_name: The logical name being resolved (for error context only)
|
|
1820
|
+
correlation_id: Optional correlation ID for tracing
|
|
1821
|
+
|
|
1822
|
+
Returns:
|
|
1823
|
+
Secret value as string, or None if not found
|
|
1824
|
+
|
|
1825
|
+
Raises:
|
|
1826
|
+
SecretResolutionError: On Vault communication failures
|
|
1827
|
+
InfraAuthenticationError: If authentication fails
|
|
1828
|
+
InfraTimeoutError: If the request times out
|
|
1829
|
+
InfraUnavailableError: If Vault is unavailable (circuit breaker open)
|
|
1830
|
+
"""
|
|
1831
|
+
if self._vault_handler is None:
|
|
1832
|
+
return None
|
|
1833
|
+
|
|
1834
|
+
effective_correlation_id = correlation_id or uuid4()
|
|
1835
|
+
|
|
1836
|
+
# Parse path into mount_point, vault_path, and optional field
|
|
1837
|
+
mount_point, vault_path, field = self._parse_vault_path_components(path)
|
|
1838
|
+
|
|
1839
|
+
# Create envelope for vault.read_secret operation
|
|
1840
|
+
envelope: JsonType = {
|
|
1841
|
+
"operation": "vault.read_secret",
|
|
1842
|
+
"payload": {
|
|
1843
|
+
"path": vault_path,
|
|
1844
|
+
"mount_point": mount_point,
|
|
1845
|
+
},
|
|
1846
|
+
"correlation_id": str(effective_correlation_id),
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
try:
|
|
1850
|
+
result = await self._vault_handler.execute(envelope)
|
|
1851
|
+
|
|
1852
|
+
# Extract secret data from handler response
|
|
1853
|
+
# Response format: {"status": "success", "payload": {"data": {...}, "metadata": {...}}}
|
|
1854
|
+
result_dict = result.result
|
|
1855
|
+
if not isinstance(result_dict, dict):
|
|
1856
|
+
logger.warning(
|
|
1857
|
+
"Unexpected Vault response format for secret: %s",
|
|
1858
|
+
logical_name,
|
|
1859
|
+
extra={
|
|
1860
|
+
"logical_name": logical_name,
|
|
1861
|
+
"correlation_id": str(effective_correlation_id),
|
|
1862
|
+
},
|
|
1863
|
+
)
|
|
1864
|
+
return None
|
|
1865
|
+
|
|
1866
|
+
status = result_dict.get("status")
|
|
1867
|
+
if status != "success":
|
|
1868
|
+
logger.debug(
|
|
1869
|
+
"Vault returned non-success status for secret: %s",
|
|
1870
|
+
logical_name,
|
|
1871
|
+
extra={
|
|
1872
|
+
"logical_name": logical_name,
|
|
1873
|
+
"correlation_id": str(effective_correlation_id),
|
|
1874
|
+
},
|
|
1875
|
+
)
|
|
1876
|
+
return None
|
|
1877
|
+
|
|
1878
|
+
payload = result_dict.get("payload", {})
|
|
1879
|
+
if not isinstance(payload, dict):
|
|
1880
|
+
return None
|
|
1881
|
+
|
|
1882
|
+
secret_data = payload.get("data", {})
|
|
1883
|
+
if not isinstance(secret_data, dict) or not secret_data:
|
|
1884
|
+
logger.debug(
|
|
1885
|
+
"No secret data found in Vault for: %s",
|
|
1886
|
+
logical_name,
|
|
1887
|
+
extra={
|
|
1888
|
+
"logical_name": logical_name,
|
|
1889
|
+
"correlation_id": str(effective_correlation_id),
|
|
1890
|
+
},
|
|
1891
|
+
)
|
|
1892
|
+
return None
|
|
1893
|
+
|
|
1894
|
+
# Extract the specific field or first value
|
|
1895
|
+
if field:
|
|
1896
|
+
value = secret_data.get(field)
|
|
1897
|
+
if value is None:
|
|
1898
|
+
logger.debug(
|
|
1899
|
+
"Field not found in Vault secret: %s",
|
|
1900
|
+
logical_name,
|
|
1901
|
+
extra={
|
|
1902
|
+
"logical_name": logical_name,
|
|
1903
|
+
"correlation_id": str(effective_correlation_id),
|
|
1904
|
+
},
|
|
1905
|
+
)
|
|
1906
|
+
return None
|
|
1907
|
+
else:
|
|
1908
|
+
# No field specified - return first value
|
|
1909
|
+
value = next(iter(secret_data.values()), None)
|
|
1910
|
+
|
|
1911
|
+
if value is None:
|
|
1912
|
+
return None
|
|
1913
|
+
|
|
1914
|
+
# SECURITY: String conversion is INTENTIONAL for SecretStr compatibility.
|
|
1915
|
+
#
|
|
1916
|
+
# SecretStr (from Pydantic) only wraps string values to prevent accidental
|
|
1917
|
+
# logging of secrets. Since SecretResolver returns SecretStr, all Vault
|
|
1918
|
+
# values must be converted to strings.
|
|
1919
|
+
#
|
|
1920
|
+
# Type conversion behavior:
|
|
1921
|
+
# - Strings: returned as-is
|
|
1922
|
+
# - Integers: "123" (str representation)
|
|
1923
|
+
# - Booleans: "True" or "False" (Python str representation)
|
|
1924
|
+
# - Lists/Dicts: Python repr (NOT JSON) - avoid storing complex types
|
|
1925
|
+
#
|
|
1926
|
+
# Best Practice: Store secrets as strings in Vault. If you need structured
|
|
1927
|
+
# data, store it as a JSON string and parse after resolution.
|
|
1928
|
+
return str(value)
|
|
1929
|
+
|
|
1930
|
+
except InfraAuthenticationError:
|
|
1931
|
+
# Re-raise auth errors directly - they have proper context
|
|
1932
|
+
raise
|
|
1933
|
+
except InfraTimeoutError:
|
|
1934
|
+
# Re-raise timeout errors directly - they have proper context
|
|
1935
|
+
raise
|
|
1936
|
+
except InfraUnavailableError:
|
|
1937
|
+
# Re-raise unavailable errors (circuit breaker open)
|
|
1938
|
+
raise
|
|
1939
|
+
except Exception as e:
|
|
1940
|
+
# Wrap other errors in SecretResolutionError with sanitized message
|
|
1941
|
+
context = ModelInfraErrorContext.with_correlation(
|
|
1942
|
+
correlation_id=effective_correlation_id,
|
|
1943
|
+
transport_type=EnumInfraTransportType.VAULT,
|
|
1944
|
+
operation="read_secret",
|
|
1945
|
+
target_name="secret_resolver",
|
|
1946
|
+
)
|
|
1947
|
+
raise SecretResolutionError(
|
|
1948
|
+
f"Failed to resolve secret from Vault: {logical_name}",
|
|
1949
|
+
context=context,
|
|
1950
|
+
logical_name=logical_name,
|
|
1951
|
+
) from e
|
|
1952
|
+
|
|
1953
|
+
def _parse_vault_path(self, path: str) -> tuple[str, str | None]:
|
|
1954
|
+
"""Parse Vault path into path and optional field.
|
|
1955
|
+
|
|
1956
|
+
Examples:
|
|
1957
|
+
"secret/data/db#password" -> ("secret/data/db", "password")
|
|
1958
|
+
"secret/data/db" -> ("secret/data/db", None)
|
|
1959
|
+
|
|
1960
|
+
Args:
|
|
1961
|
+
path: Vault path with optional field specifier
|
|
1962
|
+
|
|
1963
|
+
Returns:
|
|
1964
|
+
Tuple of (vault_path, field_name or None)
|
|
1965
|
+
"""
|
|
1966
|
+
if "#" in path:
|
|
1967
|
+
vault_path, field = path.rsplit("#", 1)
|
|
1968
|
+
return vault_path, field
|
|
1969
|
+
return path, None
|
|
1970
|
+
|
|
1971
|
+
def _parse_vault_path_components(self, path: str) -> tuple[str, str, str | None]:
|
|
1972
|
+
"""Parse Vault path into mount_point, path, and optional field.
|
|
1973
|
+
|
|
1974
|
+
The path format is: "mount_point/path/to/secret#field"
|
|
1975
|
+
|
|
1976
|
+
For KV v2 secrets engine, the path convention is:
|
|
1977
|
+
- mount_point: The secrets engine mount (e.g., "secret")
|
|
1978
|
+
- path: The secret path within the mount (e.g., "myapp/db")
|
|
1979
|
+
- field: Optional specific field to extract (e.g., "password")
|
|
1980
|
+
|
|
1981
|
+
Examples:
|
|
1982
|
+
"secret/myapp/db#password" -> ("secret", "myapp/db", "password")
|
|
1983
|
+
"secret/myapp/db" -> ("secret", "myapp/db", None)
|
|
1984
|
+
"kv/prod/config#api_key" -> ("kv", "prod/config", "api_key")
|
|
1985
|
+
"secret#password" -> ("secret", "", "password") # Edge case - unusual format
|
|
1986
|
+
|
|
1987
|
+
Args:
|
|
1988
|
+
path: Full Vault path with optional field specifier
|
|
1989
|
+
|
|
1990
|
+
Returns:
|
|
1991
|
+
Tuple of (mount_point, vault_path, field_name or None)
|
|
1992
|
+
"""
|
|
1993
|
+
# First extract field if present
|
|
1994
|
+
if "#" in path:
|
|
1995
|
+
path_without_field, field = path.rsplit("#", 1)
|
|
1996
|
+
else:
|
|
1997
|
+
path_without_field = path
|
|
1998
|
+
field = None
|
|
1999
|
+
|
|
2000
|
+
# Split into mount_point and rest of path
|
|
2001
|
+
# First component is always the mount_point
|
|
2002
|
+
parts = path_without_field.split("/", 1)
|
|
2003
|
+
if len(parts) == 1:
|
|
2004
|
+
# Edge case: No slash in path - entire path is treated as mount_point
|
|
2005
|
+
# with empty vault_path. This format (e.g., "secret#field") is unusual
|
|
2006
|
+
# and may not be supported by Vault's KV v2 engine which expects
|
|
2007
|
+
# paths like "mount/path". Log a warning to alert operators.
|
|
2008
|
+
logger.warning(
|
|
2009
|
+
"Unusual Vault path format detected. "
|
|
2010
|
+
"Expected 'mount/path#field' format with at least one '/' separator. "
|
|
2011
|
+
"Empty path segment may not work with Vault KV v2.",
|
|
2012
|
+
)
|
|
2013
|
+
return parts[0], "", field
|
|
2014
|
+
|
|
2015
|
+
mount_point = parts[0]
|
|
2016
|
+
vault_path = parts[1]
|
|
2017
|
+
|
|
2018
|
+
return mount_point, vault_path, field
|
|
2019
|
+
|
|
2020
|
+
def _cache_secret(
|
|
2021
|
+
self,
|
|
2022
|
+
logical_name: str,
|
|
2023
|
+
value: SecretStr,
|
|
2024
|
+
source_type: SecretSourceType,
|
|
2025
|
+
) -> None:
|
|
2026
|
+
"""Cache a resolved secret with appropriate TTL and jitter.
|
|
2027
|
+
|
|
2028
|
+
TTL Jitter:
|
|
2029
|
+
A symmetric random jitter of ±10% is added to the base TTL to
|
|
2030
|
+
prevent cache stampede scenarios where many cached entries expire
|
|
2031
|
+
at the same time, causing a thundering herd of resolution requests.
|
|
2032
|
+
The symmetric distribution provides better spread than additive-only
|
|
2033
|
+
jitter, reducing the probability of clustered expirations.
|
|
2034
|
+
|
|
2035
|
+
Args:
|
|
2036
|
+
logical_name: The logical name being cached
|
|
2037
|
+
value: The secret value to cache
|
|
2038
|
+
source_type: Source type for TTL selection
|
|
2039
|
+
"""
|
|
2040
|
+
base_ttl_seconds = self._get_ttl(logical_name, source_type)
|
|
2041
|
+
# Add ±10% jitter to prevent cache stampede (thundering herd)
|
|
2042
|
+
jitter_factor = random.uniform(
|
|
2043
|
+
-CACHE_TTL_JITTER_PERCENT, CACHE_TTL_JITTER_PERCENT
|
|
2044
|
+
)
|
|
2045
|
+
ttl_seconds = max(1, int(base_ttl_seconds * (1 + jitter_factor)))
|
|
2046
|
+
now = datetime.now(UTC)
|
|
2047
|
+
|
|
2048
|
+
self._cache[logical_name] = ModelCachedSecret(
|
|
2049
|
+
value=value,
|
|
2050
|
+
source_type=source_type,
|
|
2051
|
+
logical_name=logical_name,
|
|
2052
|
+
cached_at=now,
|
|
2053
|
+
expires_at=now + timedelta(seconds=ttl_seconds),
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
def _get_ttl(self, logical_name: str, source_type: SecretSourceType) -> int:
|
|
2057
|
+
"""Get TTL for a secret based on source type or override.
|
|
2058
|
+
|
|
2059
|
+
Args:
|
|
2060
|
+
logical_name: The logical name for TTL override lookup
|
|
2061
|
+
source_type: Source type for default TTL selection
|
|
2062
|
+
|
|
2063
|
+
Returns:
|
|
2064
|
+
TTL in seconds
|
|
2065
|
+
"""
|
|
2066
|
+
# Check for explicit override
|
|
2067
|
+
if logical_name in self._ttl_overrides:
|
|
2068
|
+
return self._ttl_overrides[logical_name]
|
|
2069
|
+
|
|
2070
|
+
# Use default based on source type
|
|
2071
|
+
ttl_defaults = {
|
|
2072
|
+
"env": self._config.default_ttl_env_seconds,
|
|
2073
|
+
"file": self._config.default_ttl_file_seconds,
|
|
2074
|
+
"vault": self._config.default_ttl_vault_seconds,
|
|
2075
|
+
}
|
|
2076
|
+
return ttl_defaults.get(source_type, self._config.default_ttl_env_seconds)
|
|
2077
|
+
|
|
2078
|
+
def _mask_source_path(self, source: ModelSecretSourceSpec) -> str:
|
|
2079
|
+
"""Mask sensitive parts of source path for introspection.
|
|
2080
|
+
|
|
2081
|
+
This ensures that introspection never reveals sensitive information
|
|
2082
|
+
while still being useful for debugging.
|
|
2083
|
+
|
|
2084
|
+
Args:
|
|
2085
|
+
source: Source specification to mask
|
|
2086
|
+
|
|
2087
|
+
Returns:
|
|
2088
|
+
Masked path string safe for logging/display
|
|
2089
|
+
"""
|
|
2090
|
+
if source.source_type == "env":
|
|
2091
|
+
# Show env var name but mask the value context
|
|
2092
|
+
return f"env:{source.source_path}"
|
|
2093
|
+
elif source.source_type == "file":
|
|
2094
|
+
# Show directory but mask filename
|
|
2095
|
+
path = Path(source.source_path)
|
|
2096
|
+
return f"file:{path.parent}/***"
|
|
2097
|
+
elif source.source_type == "vault":
|
|
2098
|
+
# Show mount but mask the rest
|
|
2099
|
+
parts = source.source_path.split("/")
|
|
2100
|
+
if len(parts) > 2:
|
|
2101
|
+
return f"vault:{parts[0]}/{parts[1]}/***"
|
|
2102
|
+
return "vault:***"
|
|
2103
|
+
return "***"
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
__all__: list[str] = [
|
|
2107
|
+
"ProtocolSecretResolverMetrics",
|
|
2108
|
+
"SecretResolver",
|
|
2109
|
+
"SecretSourceType",
|
|
2110
|
+
]
|