queryapigate 0.46.0__tar.gz → 0.47.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queryapigate-0.46.0/queryapigate.egg-info → queryapigate-0.47.0}/PKG-INFO +1 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/app.py +13 -13
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/config.py +9 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/dataplane.py +23 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/experimental.py +3 -3
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/governance.py +22 -2
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/gunicorn_conf.py +15 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/mcp_server.py +20 -3
- {queryapigate-0.46.0 → queryapigate-0.47.0/queryapigate.egg-info}/PKG-INFO +1 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_dataplane.py +52 -6
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_helm.py +57 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_integration.py +2 -2
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_mcp.py +76 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_queryapigate.py +33 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_runstore.py +19 -2
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_settings.py +1 -1
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_supply_chain.py +4 -2
- {queryapigate-0.46.0 → queryapigate-0.47.0}/LICENSE +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/NOTICE +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/README.md +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/pyproject.toml +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/accesslog.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/adminroles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/admins.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/alerts.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/apikeys.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/auditstream.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/aws.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/bundle.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/cli.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/configcode.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/AccessMapPage-CvmP9Sfw.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/AdministratorsPage-B1G7Dgxv.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/AgentsPage-ex_lhxAd.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/AlertsPage-CkFfLPBu.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/ApiKeyDetailPage-DluS3aag.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/ApiKeysPage-D3xeaQlS.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/ApprovalsPage-BYL5hXGT.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/AuditLogPage-TAMz5LIn.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/CachingPage-8OhOUgxW.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/CodeBox-Cwh5KHLp.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/ConnectionsPage-mHkn5I8c.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/DesignerPage-CPZneqCQ.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/DestinationsPage-Dy6NYnv3.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/Experimental-B3dnaeV7.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/ExportsPage-BzDOR2fh.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/HelpPage-BV_YH8GV.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/HomePage-BShpQIil.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/KeyActivity-DuTyhSGT.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/Managed-CQ0VXNNZ.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/McpPage-JMpMLJIz.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/MetricsPage-BOUZqgao.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/QueryInfo-n4U1MkL1.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/RepositoryPage-Cj_ZwMQj.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/Results-6QW3JK2B.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/RolesPage-Dq83u-am.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/SchemaBrowser-BzZ6DZjO.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/SettingsPage-CpjtKQ1P.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/StatTile-Cft4kJPH.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/Time-DurHvFXj.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/WorkspacesPage-Bfe_wYYe.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/api-CgyYL3VS.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/client-ZHNiMG8H.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/forms-DR_JyNks.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/forms-DdFqq646.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/icons-CFXE8hAz.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/index-0v-rImZs.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/index-Cv64XKNs.css +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/maskRules-CvKQIPgN.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/reach-BAOLd75Y.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/shared-D8E1i8vU.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/assets/useMutation-D4M562eQ.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/console_dist/index.html +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/databases.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/db.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/definitions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/destinations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/embeddings.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/engine.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/events.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/exporting.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/exports.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/formats.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/history.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/instances.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/limits.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/masking.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/metacache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/metrics.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/mongodocs.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/oidc.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/openapi.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/params.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/parquet.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/pool.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/quotas.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/ratelimit.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/rekey.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/runners.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/runstore.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/schema.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/scope.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/secretrefs.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/access.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/services/workspaces.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/sqltools.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/sso.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/store.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/tableguard.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/tracing.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/usage.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/v1.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/v1_spec.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/vault.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate/workspaces.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate.egg-info/SOURCES.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate.egg-info/requires.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/setup.cfg +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_adminroles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_admins.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_alerts.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_api_v1.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_audit_log.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_audit_siem.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_aws.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_broadcast.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_cli.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_collections.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_configcode.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_console.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_data_protection.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_databases.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_db.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_dependency_floors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_deprecations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_descriptions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_destinations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_docs_site.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_embeddings.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_errors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_events.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_events_server.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_examples.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_experimental.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_export_security.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_exporting.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_exports.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_fips.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_history.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_howto.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_instances.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_jwt.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_limits.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_masking.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_metacache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_mongo_streaming.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_mongotools.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_multinode.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_observability.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_oidc.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_params.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_parquet.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_pool.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_publishing.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_query_window.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_quotas.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_rekey.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_roles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_rolling_upgrade.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_runstore_differential.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_secretrefs.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_security_headers.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_shared_rate_limit.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_sql_guard_fuzz.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_sql_guard_writes.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_sso.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_tableguard.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_tracing.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_upgrades.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_usage.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_vault.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_workspace_config_cli.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_workspace_isolation.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_workspace_observability.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_workspace_store.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.47.0}/tests/test_workspaces_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.47.0
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
"""The Flask application: HTTP routes on top of the store, engine and formatters."""
|
|
2
|
-
import ipaddress
|
|
3
2
|
import json
|
|
4
3
|
import logging
|
|
5
4
|
import math
|
|
@@ -178,8 +177,9 @@ class _CapturePeer:
|
|
|
178
177
|
Behind a data plane (dataplane.py, ADR 0028) every request comes from it, over loopback: one that carries its
|
|
179
178
|
secret names the client's own socket address in X-QAG-Peer, and that address is taken as the real one - so this
|
|
180
179
|
server sees the request as if the client had connected directly, ProxyFix and QUERYAPIGATE_TRUST_PROXY included.
|
|
181
|
-
A peer that isn't an IP address counts as remote, as a Unix socket does.
|
|
182
|
-
|
|
180
|
+
A peer that isn't an IP address counts as remote, as a Unix socket does. A data plane from another release
|
|
181
|
+
(X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()). Its headers are
|
|
182
|
+
removed here, so nothing after this layer reads, logs or passes on the secret."""
|
|
183
183
|
|
|
184
184
|
def __init__(self, app):
|
|
185
185
|
self.app = app
|
|
@@ -188,21 +188,20 @@ class _CapturePeer:
|
|
|
188
188
|
supplied = environ.pop(dataplane.SECRET_ENVIRON, None)
|
|
189
189
|
peer = environ.pop(dataplane.PEER_ENVIRON, None)
|
|
190
190
|
wanted = environ.pop(dataplane.WANTED_ENVIRON, None)
|
|
191
|
+
version = environ.pop(dataplane.VERSION_ENVIRON, None)
|
|
191
192
|
if supplied is not None and dataplane.matches(supplied):
|
|
192
|
-
|
|
193
|
+
is_paired = dataplane.paired(version)
|
|
194
|
+
environ['queryapigate.dataplane'] = 'plan' if wanted == '1' and is_paired else True
|
|
195
|
+
environ['queryapigate.dataplane_paired'] = is_paired
|
|
193
196
|
environ['REMOTE_ADDR'] = peer if dataplane.is_address(peer) else ''
|
|
194
197
|
environ['queryapigate.peer_addr'] = environ.get('REMOTE_ADDR')
|
|
195
198
|
return self.app(environ, start_response)
|
|
196
199
|
|
|
197
200
|
|
|
198
201
|
def _peer_is_local():
|
|
199
|
-
"""Whether the request's real socket peer is a loopback address.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
return ipaddress.ip_address(peer).is_loopback
|
|
204
|
-
except ValueError:
|
|
205
|
-
return False
|
|
202
|
+
"""Whether the request's real socket peer is a loopback address (governance.is_loopback(), the MCP server's rule
|
|
203
|
+
too). Fails closed: in open-access mode a caller it can't place must configure a key."""
|
|
204
|
+
return governance.is_loopback(request.environ.get('queryapigate.peer_addr'))
|
|
206
205
|
|
|
207
206
|
|
|
208
207
|
def create_app():
|
|
@@ -362,8 +361,7 @@ def create_app():
|
|
|
362
361
|
# could reach the port would otherwise be an unrestricted admin. The real socket peer decides, never a
|
|
363
362
|
# forgeable header. The public endpoints (health, docs, the Console shell) are reachable either way.
|
|
364
363
|
if g.permission is apikeys.OPEN and request.endpoint not in PUBLIC_ENDPOINTS and not _peer_is_local():
|
|
365
|
-
raise ApiError(
|
|
366
|
-
'QUERYAPIGATE_API_KEY to allow access from other hosts.', 403, code='open_access_remote')
|
|
364
|
+
raise ApiError(governance.OPEN_ACCESS_REMOTE, 403, code='open_access_remote')
|
|
367
365
|
if g.permission is not None:
|
|
368
366
|
g.permission = settle_workspace(g.permission, named)
|
|
369
367
|
limited = check_key_rate_limit()
|
|
@@ -1282,6 +1280,8 @@ def dataplane_lease():
|
|
|
1282
1280
|
there. 404 without a run store."""
|
|
1283
1281
|
if not runstore.enabled():
|
|
1284
1282
|
return jsonify(error_body('No run store is configured', 404)), 404
|
|
1283
|
+
if not request.environ.get('queryapigate.dataplane_paired'):
|
|
1284
|
+
return jsonify(error_body(dataplane.UNPAIRED, 409)), 409
|
|
1285
1285
|
body = request.get_json(silent=True) or {}
|
|
1286
1286
|
holder = body.get('holder')
|
|
1287
1287
|
if not isinstance(holder, str) or not 0 < len(holder) <= 200:
|
|
@@ -244,6 +244,13 @@ def threads():
|
|
|
244
244
|
return int(raw) if raw.isdigit() and 1 <= int(raw) <= MAX_THREADS else DEFAULT_THREADS
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
def bind():
|
|
248
|
+
"""QUERYAPIGATE_BIND: the address the Docker image's gunicorn listens on (gunicorn_conf.py) - the image sets
|
|
249
|
+
0.0.0.0:5000, and behind the Rust data plane, which then takes that port, 127.0.0.1:5001 (ADR 0030). None when
|
|
250
|
+
unset: gunicorn's own default, or its --bind."""
|
|
251
|
+
return os.environ.get('QUERYAPIGATE_BIND', '').strip() or None
|
|
252
|
+
|
|
253
|
+
|
|
247
254
|
def access_log():
|
|
248
255
|
"""QUERYAPIGATE_ACCESS_LOG: the image's gunicorn writes an access line per request to stdout (on by default). Off
|
|
249
256
|
for a server whose proxy or load balancer already logs requests - what a line costs, with no one reading it."""
|
|
@@ -1500,6 +1507,8 @@ def describe_settings():
|
|
|
1500
1507
|
'description': 'Requests served at once, and idle connections kept open per distinct connection.', 'rows': [
|
|
1501
1508
|
row('Threads', "Requests one server process serves at once - the Docker image's gunicorn. Read at "
|
|
1502
1509
|
'startup.', 'QUERYAPIGATE_THREADS', str(threads())),
|
|
1510
|
+
row('Listen address', "Where the Docker image's gunicorn listens: 127.0.0.1:5001 behind the data plane, "
|
|
1511
|
+
'which takes the public port. Read at startup.', 'QUERYAPIGATE_BIND', bind() or "gunicorn's own"),
|
|
1503
1512
|
row('Pool size', 'Idle connections kept per connection - by default, one per thread. 0 disables pooling.',
|
|
1504
1513
|
'QUERYAPIGATE_POOL_SIZE', str(pool_size())),
|
|
1505
1514
|
row('Idle timeout', 'How long an idle pooled connection is kept before it is closed.',
|
|
@@ -15,6 +15,10 @@ the rows it was computed from (the key, the saved query, the connection), so the
|
|
|
15
15
|
whose rows digest the same, and a masked plan expires with the schema its trace was made from. The data plane never
|
|
16
16
|
decides anything of its own: it applies a plan, or it forwards.
|
|
17
17
|
|
|
18
|
+
A data plane from another release gets no plan, and no run store lease (app.py): the protocol between the two is
|
|
19
|
+
internal, so they must come from one release (X-QAG-Dataplane-Version, ADR 0030 section 1). It then forwards every call,
|
|
20
|
+
which is slower but answers as Python does.
|
|
21
|
+
|
|
18
22
|
Saved queries with `{name}` text placeholders never get a plan: their values are part of the SQL text, so the verdict
|
|
19
23
|
can change from one call to the next.
|
|
20
24
|
"""
|
|
@@ -22,12 +26,16 @@ import base64
|
|
|
22
26
|
import hmac
|
|
23
27
|
import ipaddress
|
|
24
28
|
import json
|
|
29
|
+
import logging
|
|
30
|
+
|
|
31
|
+
from . import __version__, config, runners, sqltools
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
log = logging.getLogger('queryapigate')
|
|
27
34
|
|
|
28
35
|
SECRET_HEADER = 'X-QAG-Dataplane'
|
|
29
36
|
PEER_HEADER = 'X-QAG-Peer'
|
|
30
37
|
WANTED_HEADER = 'X-QAG-Plan-Wanted'
|
|
38
|
+
VERSION_HEADER = 'X-QAG-Dataplane-Version' # the data plane's release, sent with its secret
|
|
31
39
|
PLAN_HEADER = 'X-QAG-Plan'
|
|
32
40
|
VERSION = 1
|
|
33
41
|
DIALECTS = ('postgres', 'mysql') # the two the prototype serves - every other connection is forwarded
|
|
@@ -38,6 +46,10 @@ SECRET_ENVIRON = 'HTTP_X_QAG_DATAPLANE' # the two headers as WSGI names them -
|
|
|
38
46
|
PEER_ENVIRON = 'HTTP_X_QAG_PEER'
|
|
39
47
|
# The data plane asks for a plan only when it would keep one: other calls it forwards keep the metadata cache
|
|
40
48
|
WANTED_ENVIRON = 'HTTP_X_QAG_PLAN_WANTED'
|
|
49
|
+
VERSION_ENVIRON = 'HTTP_X_QAG_DATAPLANE_VERSION'
|
|
50
|
+
UNPAIRED = ('This data plane is from another release than this server: it gets no plans and no run store lease '
|
|
51
|
+
'until both are the same release')
|
|
52
|
+
_unpaired_seen: set = set()
|
|
41
53
|
|
|
42
54
|
|
|
43
55
|
def matches(supplied):
|
|
@@ -47,6 +59,16 @@ def matches(supplied):
|
|
|
47
59
|
return bool(secret and supplied) and hmac.compare_digest(supplied.encode('utf-8', 'replace'), secret.encode())
|
|
48
60
|
|
|
49
61
|
|
|
62
|
+
def paired(version):
|
|
63
|
+
"""Whether a data plane that sent `version` is from this server's release - logged once per other version."""
|
|
64
|
+
if version == __version__:
|
|
65
|
+
return True
|
|
66
|
+
if version not in _unpaired_seen:
|
|
67
|
+
_unpaired_seen.add(version)
|
|
68
|
+
log.warning('%s (data plane %s, this server %s).', UNPAIRED, version or 'unknown', __version__)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
50
72
|
def is_address(peer):
|
|
51
73
|
"""Whether X-QAG-Peer names an IP address - the client's socket address, as the data plane saw it."""
|
|
52
74
|
try:
|
|
@@ -70,10 +70,10 @@ FEATURES: dict[str, dict[str, Any]] = {
|
|
|
70
70
|
},
|
|
71
71
|
'dataplane': {
|
|
72
72
|
'name': 'The Rust data plane in front of /q/',
|
|
73
|
-
'why': '
|
|
74
|
-
'
|
|
73
|
+
'why': 'decided go in 0.44 (ADR 0028) and shipped in the image and the chart (ADR 0030); what it serves, its '
|
|
74
|
+
'settings and its headers may change before it graduates',
|
|
75
75
|
'settings': ('QUERYAPIGATE_DATAPLANE_SECRET',),
|
|
76
|
-
'docs': ('documentation/DEPLOYMENT.md', 'A Rust data plane in front of /q/
|
|
76
|
+
'docs': ('documentation/DEPLOYMENT.md', 'A Rust data plane in front of /q/'),
|
|
77
77
|
},
|
|
78
78
|
'run_store': {
|
|
79
79
|
'name': 'A ClickHouse run store for history, the event log and usage',
|
|
@@ -10,10 +10,14 @@ The limiters are held on the Flask app. With QUERYAPIGATE_REDIS_URL set they cou
|
|
|
10
10
|
process - `queryapigate serve`, `queryapigate mcp`, `queryapigate events` - shares one budget per client and per key
|
|
11
11
|
(BACKLOG #55); without it each process counts on its own.
|
|
12
12
|
"""
|
|
13
|
+
import ipaddress
|
|
13
14
|
from dataclasses import dataclass
|
|
14
15
|
|
|
15
16
|
from . import admins, alerts, apikeys, config, jwtauth, metrics
|
|
16
17
|
|
|
18
|
+
OPEN_ACCESS_REMOTE = ('This server has no API key set, so it answers only local clients. Set QUERYAPIGATE_API_KEY '
|
|
19
|
+
'to allow access from other hosts.')
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
@dataclass(frozen=True)
|
|
19
23
|
class Verdict:
|
|
@@ -24,6 +28,15 @@ class Verdict:
|
|
|
24
28
|
retry_after: int
|
|
25
29
|
|
|
26
30
|
|
|
31
|
+
def is_loopback(peer):
|
|
32
|
+
"""Whether a connection's real peer is a loopback address - the one caller open-access mode answers. Fails closed:
|
|
33
|
+
an address that can't be parsed, or none at all (a Unix socket, a test client's name), counts as remote."""
|
|
34
|
+
try:
|
|
35
|
+
return ipaddress.ip_address(peer or '').is_loopback
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
27
40
|
def authenticate(api_key, authorization, client_ip):
|
|
28
41
|
"""The caller's Permission: a matched key (admin or scoped), a signed-in user's verified bearer token
|
|
29
42
|
(jwtauth.py), the unrestricted OPEN default when no key is configured anywhere, or None when a key is required
|
|
@@ -42,13 +55,20 @@ def authenticate(api_key, authorization, client_ip):
|
|
|
42
55
|
return None if apikeys.auth_required() else apikeys.OPEN
|
|
43
56
|
|
|
44
57
|
|
|
45
|
-
def authenticate_mcp(api_key, authorization, client_ip):
|
|
58
|
+
def authenticate_mcp(api_key, authorization, client_ip, peer=None):
|
|
46
59
|
"""(Permission, None) for a caller the MCP server lets in, else (None, refusal) - a jwtauth.McpRefusal saying
|
|
47
60
|
401 or 403 and why. An API key is judged as everywhere. A bearer token, with OAuth on for the MCP server
|
|
48
61
|
(ADR 0006), must be one issued for it - a REST token isn't, however valid; with OAuth off, REST's JWT settings
|
|
49
|
-
apply, as before.
|
|
62
|
+
apply, as before. Open access is for a local caller only: `client_ip` (the client a trusted proxy names, if one
|
|
63
|
+
does) and the connection's own `peer`, when it's known, must both be loopback - so neither a remote client sent
|
|
64
|
+
on by a proxy on this host, nor a forged X-Forwarded-For that a too-wide FORWARDED_ALLOW_IPS would honour, gets
|
|
65
|
+
in."""
|
|
50
66
|
if api_key or not authorization or not config.mcp_oauth_enabled():
|
|
51
67
|
permission = authenticate(api_key, authorization, client_ip)
|
|
68
|
+
if permission is apikeys.OPEN and not (is_loopback(client_ip) and (peer is None or is_loopback(peer))):
|
|
69
|
+
# No key, administrator, JWT or OAuth configured anywhere: the unrestricted OPEN caller, which only a local
|
|
70
|
+
# client may be - as REST's gate refuses it (app.py). Before 0.47 the MCP server let any client in as it.
|
|
71
|
+
return None, jwtauth.McpRefusal(403, 'open_access_remote', OPEN_ACCESS_REMOTE)
|
|
52
72
|
if permission is not None:
|
|
53
73
|
return permission, None
|
|
54
74
|
return None, jwtauth.McpRefusal(401, 'unauthorized', 'Unauthorized: missing or invalid X-API-Key or bearer '
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
(QUERYAPIGATE_THREADS, default 8), whether there is an access log at all (QUERYAPIGATE_ACCESS_LOG, on by default, to
|
|
3
3
|
stdout), and its format - paths without their query strings, unless QUERYAPIGATE_ACCESS_LOG_QUERY=1 (accesslog.py, ADR
|
|
4
4
|
0016). The image uses it; so can any gunicorn command running QueryAPIGate. A value given on gunicorn's command line,
|
|
5
|
-
or in GUNICORN_CMD_ARGS, overrides the one here, as gunicorn reads a config file first.
|
|
5
|
+
or in GUNICORN_CMD_ARGS, overrides the one here, as gunicorn reads a config file first.
|
|
6
|
+
|
|
7
|
+
Where it listens: QUERYAPIGATE_BIND, which the image sets to 0.0.0.0:5000 and the Helm chart to 127.0.0.1:5001 behind
|
|
8
|
+
the data plane; unset, gunicorn's own default.
|
|
9
|
+
|
|
10
|
+
Behind the Rust data plane (QUERYAPIGATE_DATAPLANE_SECRET set, ADR 0030) every request reaches gunicorn over loopback,
|
|
11
|
+
which gunicorn trusts by default to set the scheme (X-Forwarded-Proto, -Ssl, -Protocol) and SCRIPT_NAME from headers -
|
|
12
|
+
a client's own, passed on. So it trusts no peer's then, unless FORWARDED_ALLOW_IPS says otherwise: Python reads the
|
|
13
|
+
client's forwarding headers through ProxyFix, as QUERYAPIGATE_TRUST_PROXY says, as it does without the data plane."""
|
|
6
14
|
# Every name at the top of this file is read as a gunicorn setting of that name, and `config` is one (the config file's
|
|
7
15
|
# own path): imported under it, gunicorn refuses to start. Hence the underscores.
|
|
16
|
+
import os as _os
|
|
17
|
+
|
|
8
18
|
from queryapigate import config as _settings
|
|
9
19
|
from queryapigate.accesslog import gunicorn_format as _gunicorn_format
|
|
10
20
|
|
|
11
21
|
threads = _settings.threads()
|
|
12
22
|
accesslog = '-' if _settings.access_log() else None # None: no access log - not one written to /dev/null
|
|
13
23
|
access_log_format = _gunicorn_format()
|
|
24
|
+
if _settings.bind():
|
|
25
|
+
bind = _settings.bind()
|
|
26
|
+
if _settings.dataplane_secret() and 'FORWARDED_ALLOW_IPS' not in _os.environ:
|
|
27
|
+
forwarded_allow_ips = ''
|
|
@@ -20,6 +20,7 @@ package isn't."""
|
|
|
20
20
|
import functools
|
|
21
21
|
import json
|
|
22
22
|
import logging
|
|
23
|
+
import os
|
|
23
24
|
import re
|
|
24
25
|
import time
|
|
25
26
|
import urllib.parse
|
|
@@ -604,6 +605,17 @@ def permission_for_listing(flask_app, api_key, authorization, client_ip):
|
|
|
604
605
|
|
|
605
606
|
|
|
606
607
|
PERMISSION_STATE = 'queryapigate_permission' # where the gate leaves the caller, in the ASGI scope's state
|
|
608
|
+
SOCKET_PEER = 'queryapigate.socket_peer' # the connection's own address, kept before X-Forwarded-For can replace it
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def keep_socket_peer(app):
|
|
612
|
+
"""Record each request's real socket peer in its scope, for the gate's open-access rule, before the proxy-header
|
|
613
|
+
middleware inside it rewrites `client` from X-Forwarded-For (run())."""
|
|
614
|
+
async def wrapped(scope, receive, send):
|
|
615
|
+
if scope['type'] == 'http' and scope.get('client'):
|
|
616
|
+
scope[SOCKET_PEER] = scope['client'][0]
|
|
617
|
+
return await app(scope, receive, send)
|
|
618
|
+
return wrapped
|
|
607
619
|
|
|
608
620
|
|
|
609
621
|
def resource_metadata_url():
|
|
@@ -659,7 +671,8 @@ def gate(flask_app, inner):
|
|
|
659
671
|
headers={'Retry-After': str(verdict.retry_after), 'X-Request-Id': request_id})
|
|
660
672
|
else:
|
|
661
673
|
api_key, authorization = headers.get('x-api-key', ''), headers.get('authorization', '')
|
|
662
|
-
permission, refusal = governance.authenticate_mcp(api_key, authorization, client_ip
|
|
674
|
+
permission, refusal = governance.authenticate_mcp(api_key, authorization, client_ip,
|
|
675
|
+
peer=scope.get(SOCKET_PEER))
|
|
663
676
|
if permission is not None:
|
|
664
677
|
scope.setdefault('state', {})[PERMISSION_STATE] = permission
|
|
665
678
|
return await inner(scope, receive, send)
|
|
@@ -784,7 +797,11 @@ def build_asgi_app(flask_app):
|
|
|
784
797
|
|
|
785
798
|
|
|
786
799
|
def run(flask_app, host, port):
|
|
787
|
-
"""Serves build_asgi_app() until interrupted.
|
|
800
|
+
"""Serves build_asgi_app() until interrupted. Uvicorn's own proxy-header handling is applied here instead, with
|
|
801
|
+
the same trusted hosts (FORWARDED_ALLOW_IPS, else loopback), so the socket peer can be kept before it."""
|
|
788
802
|
import uvicorn
|
|
803
|
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
789
804
|
|
|
790
|
-
|
|
805
|
+
trusted = os.environ.get('FORWARDED_ALLOW_IPS', '127.0.0.1,::1') # uvicorn.Config's own default
|
|
806
|
+
app = keep_socket_peer(ProxyHeadersMiddleware(build_asgi_app(flask_app), trusted_hosts=trusted))
|
|
807
|
+
uvicorn.run(app, host=host, port=port, server_header=False, proxy_headers=False) # no version banner
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.47.0
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -3,17 +3,19 @@ spec the data plane is built to - that a plan, applied as the data plane applies
|
|
|
3
3
|
sends. The PostgreSQL and MySQL runners are replaced by recording fakes, so no database is needed."""
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import sqlite3
|
|
7
8
|
import tempfile
|
|
8
9
|
import unittest
|
|
9
10
|
from unittest import mock
|
|
10
11
|
|
|
11
|
-
from queryapigate import apikeys, create_app, dataplane, db, runners, runstore, schema, sqltools
|
|
12
|
+
from queryapigate import __version__, apikeys, create_app, dataplane, db, runners, runstore, schema, sqltools
|
|
12
13
|
from tests.helpers import save_query, write_connections
|
|
13
14
|
|
|
14
15
|
ADMIN = {'X-API-Key': 'admin-key'}
|
|
15
16
|
SECRET = 'dp-secret-0123456789'
|
|
16
17
|
WANTED = 'X-QAG-Plan-Wanted'
|
|
18
|
+
PAIRED = {'X-QAG-Dataplane-Version': __version__} # a data plane of this release
|
|
17
19
|
COLUMNS = ['id', 'email', 'name']
|
|
18
20
|
TABLES = {'tables': [{'name': 'customers', 'columns': [{'name': c} for c in COLUMNS]}]}
|
|
19
21
|
|
|
@@ -68,7 +70,7 @@ class PlanTestCase(unittest.TestCase):
|
|
|
68
70
|
return COLUMNS, [(1, 'a@example.com', 'A')]
|
|
69
71
|
|
|
70
72
|
def call(self, path, key, secret=SECRET):
|
|
71
|
-
headers = {'X-API-Key': key, **({dataplane.SECRET_HEADER: secret, WANTED: '1'} if secret else {})}
|
|
73
|
+
headers = {'X-API-Key': key, **({dataplane.SECRET_HEADER: secret, WANTED: '1', **PAIRED} if secret else {})}
|
|
72
74
|
res = self.client.get(path, headers=headers)
|
|
73
75
|
self.assertEqual(res.status_code, 200, res.get_data(as_text=True))
|
|
74
76
|
value = res.headers.get(dataplane.PLAN_HEADER)
|
|
@@ -109,7 +111,8 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
109
111
|
self.assertIn('at least 16 characters', str(caught.exception))
|
|
110
112
|
|
|
111
113
|
def test_never_for_an_administrator(self):
|
|
112
|
-
res = self.client.get('/q/by_id?id=1', headers={**ADMIN, dataplane.SECRET_HEADER: SECRET, WANTED: '1'
|
|
114
|
+
res = self.client.get('/q/by_id?id=1', headers={**ADMIN, dataplane.SECRET_HEADER: SECRET, WANTED: '1',
|
|
115
|
+
**PAIRED})
|
|
113
116
|
self.assertIsNone(res.headers.get(dataplane.PLAN_HEADER))
|
|
114
117
|
|
|
115
118
|
def test_never_for_a_key_with_a_rate_limit(self):
|
|
@@ -131,7 +134,7 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
131
134
|
|
|
132
135
|
def test_a_refusal_gets_none(self):
|
|
133
136
|
res = self.client.get('/q/by_id?id=x', headers={'X-API-Key': self.plain, dataplane.SECRET_HEADER: SECRET,
|
|
134
|
-
WANTED: '1'})
|
|
137
|
+
WANTED: '1', **PAIRED})
|
|
135
138
|
self.assertEqual(res.status_code, 400)
|
|
136
139
|
self.assertIsNone(res.headers.get(dataplane.PLAN_HEADER))
|
|
137
140
|
|
|
@@ -142,6 +145,49 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
142
145
|
self.assertNotEqual(before['api_key'], after['api_key'])
|
|
143
146
|
self.assertEqual(before['saved_query'], after['saved_query'])
|
|
144
147
|
|
|
148
|
+
def test_never_to_a_data_plane_from_another_release(self):
|
|
149
|
+
# The protocol between the two is internal: a pair from two releases forwards every call instead (ADR 0030)
|
|
150
|
+
for version in (None, '0.1.0', __version__ + '.1', ''):
|
|
151
|
+
with self.subTest(version=version):
|
|
152
|
+
headers = {'X-API-Key': self.plain, dataplane.SECRET_HEADER: SECRET, WANTED: '1'}
|
|
153
|
+
if version is not None:
|
|
154
|
+
headers[dataplane.VERSION_HEADER] = version
|
|
155
|
+
dataplane._unpaired_seen.discard(version)
|
|
156
|
+
with self.assertLogs('queryapigate', 'WARNING') as logged: # once for each version it meets
|
|
157
|
+
res = self.client.get('/q/by_id?id=1', headers=headers)
|
|
158
|
+
self.client.get('/q/by_id?id=1', headers=headers)
|
|
159
|
+
self.assertEqual(sum('another release' in line for line in logged.output), 1)
|
|
160
|
+
self.assertEqual(res.status_code, 200) # answered all the same
|
|
161
|
+
self.assertIsNone(res.headers.get(dataplane.PLAN_HEADER))
|
|
162
|
+
|
|
163
|
+
def test_the_data_plane_is_built_as_this_release(self):
|
|
164
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
165
|
+
with open(os.path.join(root, 'dataplane', 'Cargo.toml')) as f:
|
|
166
|
+
crate = re.search(r'^version = "([^"]+)"', f.read(), re.M).group(1)
|
|
167
|
+
with open(os.path.join(root, 'dataplane', 'Cargo.lock')) as f:
|
|
168
|
+
locked = re.search(r'name = "queryapigate-dataplane"\nversion = "([^"]+)"', f.read()).group(1)
|
|
169
|
+
self.assertEqual((crate, locked), (__version__, __version__),
|
|
170
|
+
'set dataplane/Cargo.toml\'s version to the release, then build it to update Cargo.lock')
|
|
171
|
+
|
|
172
|
+
def test_the_image_builds_it_with_the_pinned_toolchain(self):
|
|
173
|
+
# rust-toolchain.toml isn't copied into the image's build, so rustup doesn't fetch one there: the stage's
|
|
174
|
+
# image must be that toolchain, on the Debian release the Python image is on (the same glibc)
|
|
175
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
176
|
+
with open(os.path.join(root, 'dataplane', 'rust-toolchain.toml')) as f:
|
|
177
|
+
channel = re.search(r'^channel = "([^"]+)"', f.read(), re.M).group(1)
|
|
178
|
+
with open(os.path.join(root, 'Dockerfile')) as f:
|
|
179
|
+
dockerfile = f.read()
|
|
180
|
+
rust = re.search(r'^FROM --platform=\$BUILDPLATFORM rust:([\d.]+)-slim-(\w+)@sha256:\w+ AS dataplane$',
|
|
181
|
+
dockerfile, re.M)
|
|
182
|
+
python = re.search(r'^FROM python:[\d.]+-slim@sha256:\w+$', dockerfile, re.M)
|
|
183
|
+
self.assertIsNotNone(rust, 'the data plane stage: rust:<channel>-slim-<debian>@sha256:..., by digest')
|
|
184
|
+
self.assertIsNotNone(python)
|
|
185
|
+
self.assertEqual(rust.group(1), channel)
|
|
186
|
+
self.assertEqual(rust.group(2), 'trixie', 'the Debian release python:3.12-slim is on')
|
|
187
|
+
self.assertNotIn('COPY dataplane/ ', dockerfile)
|
|
188
|
+
self.assertIn('COPY --from=dataplane /queryapigate-dataplane /usr/local/bin/queryapigate-dataplane',
|
|
189
|
+
dockerfile)
|
|
190
|
+
|
|
145
191
|
def test_never_unless_the_data_plane_asks_for_one(self):
|
|
146
192
|
res = self.client.get('/q/by_id?id=1', headers={'X-API-Key': self.plain, dataplane.SECRET_HEADER: SECRET})
|
|
147
193
|
self.assertEqual(res.status_code, 200)
|
|
@@ -161,7 +207,7 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
161
207
|
db.connection().execute("UPDATE api_keys SET details_json = ? WHERE name = 'restricted'",
|
|
162
208
|
(json.dumps(details),))
|
|
163
209
|
self.assertEqual(self.client.get('/q/by_id?id=1', headers=forwarded).status_code, 200) # cached
|
|
164
|
-
wanting = {**forwarded, WANTED: '1'}
|
|
210
|
+
wanting = {**forwarded, WANTED: '1', **PAIRED}
|
|
165
211
|
self.assertEqual(self.client.get('/q/by_id?id=1', headers=wanting).status_code, 403) # read fresh
|
|
166
212
|
|
|
167
213
|
def test_the_metadata_cache_is_bypassed_for_the_data_plane(self):
|
|
@@ -169,7 +215,7 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
169
215
|
self.call('/q/by_id?id=1', self.restricted) # cached for ordinary calls, from here on
|
|
170
216
|
apikeys.update_key('restricted', allowed_tables=['orders'])
|
|
171
217
|
res = self.client.get('/q/by_id?id=1', headers={'X-API-Key': self.restricted,
|
|
172
|
-
dataplane.SECRET_HEADER: SECRET, WANTED: '1'})
|
|
218
|
+
dataplane.SECRET_HEADER: SECRET, WANTED: '1', **PAIRED})
|
|
173
219
|
self.assertEqual(res.status_code, 403) # the fresh grant, not the cached one
|
|
174
220
|
|
|
175
221
|
|
|
@@ -181,6 +181,51 @@ class ShapeTests(unittest.TestCase):
|
|
|
181
181
|
self.assertFalse(any(e['name'] == 'QUERYAPIGATE_METRICS_TOKEN' for d in by_kind(off, 'Deployment')
|
|
182
182
|
for e in container(d)['env']))
|
|
183
183
|
|
|
184
|
+
def test_the_data_plane_in_front_of_each_api_server_pod(self):
|
|
185
|
+
"""dataplane (ADR 0030): a second container from the same image takes the pod's port, Python moves to its
|
|
186
|
+
loopback, and the two share a secret the chart makes and keeps - the events and MCP pods are left as they
|
|
187
|
+
are."""
|
|
188
|
+
docs = render(*POSTGRES, '--set', 'dataplane.enabled=true', '--set', 'ingress.enabled=true',
|
|
189
|
+
'--set', 'events.enabled=true', '--set', 'mcp.enabled=true')
|
|
190
|
+
[secret] = by_kind(docs, 'Secret', 'qag-queryapigate-dataplane')
|
|
191
|
+
self.assertGreaterEqual(len(secret['data']['secret']), 48)
|
|
192
|
+
server = by_kind(docs, 'Deployment', 'qag-queryapigate')[0]
|
|
193
|
+
python, dataplane = server['spec']['template']['spec']['containers']
|
|
194
|
+
self.assertEqual((python['name'], dataplane['name']), ('queryapigate', 'dataplane'))
|
|
195
|
+
self.assertEqual((dataplane['image'], dataplane['command']), (python['image'], ['queryapigate-dataplane']))
|
|
196
|
+
shared = {'secretKeyRef': {'name': 'qag-queryapigate-dataplane', 'key': 'secret'}}
|
|
197
|
+
python_env = {e['name']: e for e in python['env']}
|
|
198
|
+
dataplane_env = {e['name']: e for e in dataplane['env']}
|
|
199
|
+
for env in (python_env, dataplane_env):
|
|
200
|
+
self.assertEqual(env['QUERYAPIGATE_DATAPLANE_SECRET']['valueFrom'], shared)
|
|
201
|
+
self.assertEqual(env['QUERYAPIGATE_TRUST_PROXY']['value'], '1') # the ingress: read by both now
|
|
202
|
+
self.assertEqual(python_env['QUERYAPIGATE_BIND']['value'], '127.0.0.1:5001')
|
|
203
|
+
self.assertEqual(dataplane_env['QUERYAPIGATE_DATAPLANE_LISTEN']['value'], '0.0.0.0:5000')
|
|
204
|
+
self.assertEqual(dataplane_env['QUERYAPIGATE_DATAPLANE_UPSTREAM']['value'], 'http://127.0.0.1:5001')
|
|
205
|
+
# the Service's port is the data plane's; Python's, on loopback, is checked from inside its container
|
|
206
|
+
self.assertEqual(dataplane['ports'], [{'name': 'http', 'containerPort': 5000}])
|
|
207
|
+
self.assertNotIn('ports', python)
|
|
208
|
+
self.assertIn('127.0.0.1:5001/health', ' '.join(python['livenessProbe']['exec']['command']))
|
|
209
|
+
self.assertEqual(dataplane['readinessProbe']['httpGet']['path'], '/health')
|
|
210
|
+
self.assertIn('tcpSocket', dataplane['livenessProbe'])
|
|
211
|
+
self.assertEqual(sorted(m['mountPath'] for m in dataplane['volumeMounts']), ['/data', '/tmp'])
|
|
212
|
+
self.assertEqual(dataplane['resources']['limits'], {'memory': '256Mi'})
|
|
213
|
+
for name in ('qag-queryapigate-events', 'qag-queryapigate-mcp'):
|
|
214
|
+
other = by_kind(docs, 'Deployment', name)[0]['spec']['template']['spec']['containers']
|
|
215
|
+
self.assertEqual(len(other), 1)
|
|
216
|
+
self.assertNotIn('QUERYAPIGATE_DATAPLANE_SECRET', {e['name'] for e in other[0]['env']})
|
|
217
|
+
mine = render(*POSTGRES, '--set', 'dataplane.enabled=true', '--set', 'dataplane.existingSecret=mine',
|
|
218
|
+
'--set', 'dataplane.key=s')
|
|
219
|
+
self.assertEqual(by_kind(mine, 'Secret', 'qag-queryapigate-dataplane'), [])
|
|
220
|
+
python = by_kind(mine, 'Deployment')[0]['spec']['template']['spec']['containers'][0]
|
|
221
|
+
self.assertEqual({e['name']: e for e in python['env']}['QUERYAPIGATE_DATAPLANE_SECRET']['valueFrom'],
|
|
222
|
+
{'secretKeyRef': {'name': 'mine', 'key': 's'}})
|
|
223
|
+
off = render(*POSTGRES)
|
|
224
|
+
self.assertEqual(by_kind(off, 'Secret', 'qag-queryapigate-dataplane'), [])
|
|
225
|
+
[python] = by_kind(off, 'Deployment')[0]['spec']['template']['spec']['containers']
|
|
226
|
+
self.assertEqual(python['ports'], [{'name': 'http', 'containerPort': 5000}])
|
|
227
|
+
self.assertNotIn('QUERYAPIGATE_BIND', {e['name'] for e in python['env']}) # the image's own: 0.0.0.0:5000
|
|
228
|
+
|
|
184
229
|
def test_the_optional_parts(self):
|
|
185
230
|
docs = render(*POSTGRES, '--set', 'serviceMonitor.enabled=true', '--set', 'networkPolicy.enabled=true',
|
|
186
231
|
'--set', 'autoscaling.enabled=true')
|
|
@@ -210,6 +255,11 @@ class HardeningTests(unittest.TestCase):
|
|
|
210
255
|
self.assertEqual(sorted(m['mountPath'] for m in ctr['volumeMounts']), ['/data', '/tmp'])
|
|
211
256
|
self.assertIn('limits', ctr['resources'])
|
|
212
257
|
self.assertEqual(ctr['readinessProbe']['httpGet']['path'], '/health')
|
|
258
|
+
# the data plane's container is held to the same
|
|
259
|
+
server = by_kind(render(*POSTGRES, '--set', 'dataplane.enabled=true'), 'Deployment')[0]
|
|
260
|
+
dataplane = server['spec']['template']['spec']['containers'][1]
|
|
261
|
+
self.assertEqual(dataplane['securityContext'], container(deployments[0])['securityContext'])
|
|
262
|
+
self.assertIn('limits', dataplane['resources'])
|
|
213
263
|
|
|
214
264
|
|
|
215
265
|
@unittest.skipUnless(HELM, 'set QUERYAPIGATE_TEST_HELM, or put helm on PATH')
|
|
@@ -240,7 +290,13 @@ class RefusalTests(unittest.TestCase):
|
|
|
240
290
|
'--set', 'configAsCode.existingConfigMap=y'), 'choose one'),
|
|
241
291
|
((*SECRET, '--set', 'config.QUERYAPIGATE_CONFIG_FILE=/x'), 'through configAsCode'),
|
|
242
292
|
((*SECRET, '--set', 'config.QUERYAPIGATE_METRICS_TOKEN=x'), 'comes from metricsAuth'),
|
|
243
|
-
((*SECRET, '--set', 'secret.values.QUERYAPIGATE_METRICS_TOKEN=x'), 'comes from metricsAuth')
|
|
293
|
+
((*SECRET, '--set', 'secret.values.QUERYAPIGATE_METRICS_TOKEN=x'), 'comes from metricsAuth'),
|
|
294
|
+
((*SECRET, '--set', 'config.QUERYAPIGATE_DATAPLANE_SECRET=x'),
|
|
295
|
+
'QUERYAPIGATE_DATAPLANE_SECRET is a secret'),
|
|
296
|
+
((*SECRET, '--set', 'dataplane.enabled=true', '--set', 'secret.values.QUERYAPIGATE_DATAPLANE_SECRET=x'),
|
|
297
|
+
'comes from dataplane'),
|
|
298
|
+
((*SECRET, '--set', 'config.QUERYAPIGATE_BIND=0.0.0.0:8080'), "is the chart's to set"),
|
|
299
|
+
((*SECRET, '--set', 'dataplane.threads=4'), 'threads')): # not a value: config has its settings
|
|
244
300
|
failed, stderr = refused(*args)
|
|
245
301
|
self.assertTrue(failed, args)
|
|
246
302
|
self.assertIn(words, stderr, args)
|
|
@@ -342,8 +342,8 @@ class IntegrationBase:
|
|
|
342
342
|
return self.client.post('/api/v1/queries', json={'name': name, 'description': 'd', 'sql': sql,
|
|
343
343
|
'connection_name': 'it', 'publish': True, **fields})
|
|
344
344
|
|
|
345
|
-
def call(tool, **arguments):
|
|
346
|
-
return mcp_server.handle_call(self.client.application, '', '', '
|
|
345
|
+
def call(tool, **arguments): # with no key set, as a local client: open access answers no other
|
|
346
|
+
return mcp_server.handle_call(self.client.application, '', '', '127.0.0.1', tool,
|
|
347
347
|
arguments)['structuredContent']
|
|
348
348
|
|
|
349
349
|
def names():
|
|
@@ -59,6 +59,82 @@ class McpTestCase(unittest.TestCase):
|
|
|
59
59
|
return res.get_json()['secret']
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
@unittest.skipUnless(HAVE_MCP_SDK, 'the mcp package is not installed')
|
|
63
|
+
class OpenAccessTests(McpTestCase):
|
|
64
|
+
"""With no key, administrator, JWT or OAuth configured, the MCP server answers only local clients - as REST's gate
|
|
65
|
+
does (open_access_remote) - however a client reaches it. Before 0.47 it let any client in as the unrestricted OPEN
|
|
66
|
+
caller: every saved query, ad-hoc SQL on every connection, an agent's writes."""
|
|
67
|
+
|
|
68
|
+
def setUp(self):
|
|
69
|
+
super().setUp()
|
|
70
|
+
self.save('q1')
|
|
71
|
+
os.environ.pop('QUERYAPIGATE_API_KEY') # the patch.dict in McpTestCase restores it
|
|
72
|
+
self.assertFalse(apikeys.auth_required())
|
|
73
|
+
|
|
74
|
+
def http(self, peer):
|
|
75
|
+
from starlette.testclient import TestClient
|
|
76
|
+
client = TestClient(mcp_server.build_asgi_app(self.app), client=(peer, 50000))
|
|
77
|
+
client.__enter__()
|
|
78
|
+
self.addCleanup(client.__exit__, None, None, None)
|
|
79
|
+
return client
|
|
80
|
+
|
|
81
|
+
def initialize(self, client, headers=None):
|
|
82
|
+
return client.post('/mcp/', headers={'Accept': 'application/json, text/event-stream', **(headers or {})},
|
|
83
|
+
json={'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
|
|
84
|
+
'params': {'protocolVersion': '2025-06-18', 'capabilities': {},
|
|
85
|
+
'clientInfo': {'name': 'test', 'version': '1'}}})
|
|
86
|
+
|
|
87
|
+
def test_a_remote_client_is_refused_before_anything_is_answered(self):
|
|
88
|
+
# 172.17.0.1 is how a client reaching `docker run -p` looks from inside the container, even from the host
|
|
89
|
+
for peer in ('203.0.113.9', '172.17.0.1', '10.0.0.5', '::ffff:10.0.0.5', 'testclient'):
|
|
90
|
+
with self.subTest(peer=peer):
|
|
91
|
+
res = self.initialize(self.http(peer))
|
|
92
|
+
self.assertEqual((res.status_code, res.json()['code']), (403, 'open_access_remote'))
|
|
93
|
+
self.assertIn('QUERYAPIGATE_API_KEY', res.json()['error'])
|
|
94
|
+
|
|
95
|
+
def test_a_local_client_is_answered_as_before(self):
|
|
96
|
+
for peer in ('127.0.0.1', '::1'):
|
|
97
|
+
with self.subTest(peer=peer):
|
|
98
|
+
self.assertEqual(self.initialize(self.http(peer)).status_code, 200)
|
|
99
|
+
|
|
100
|
+
def test_a_tool_call_and_the_tool_listing_judge_the_peer_too(self):
|
|
101
|
+
refused = mcp_server.handle_call(self.app, '', '', '203.0.113.9', 'q1', {})
|
|
102
|
+
self.assertTrue(refused['isError'])
|
|
103
|
+
self.assertEqual(refused['structuredContent']['code'], 'open_access_remote')
|
|
104
|
+
self.assertFalse(mcp_server.handle_call(self.app, '', '', '127.0.0.1', 'q1', {}).get('isError'))
|
|
105
|
+
self.assertIsNone(mcp_server.permission_for_listing(self.app, '', '', '203.0.113.9'))
|
|
106
|
+
self.assertIsNone(mcp_server.permission_for_listing(self.app, '', '', None)) # no address: remote
|
|
107
|
+
self.assertIs(mcp_server.permission_for_listing(self.app, '', '', '127.0.0.1'), apikeys.OPEN)
|
|
108
|
+
|
|
109
|
+
def served(self, peer, trusted):
|
|
110
|
+
"""A client as run() serves it: the socket peer kept, then Uvicorn's proxy headers with `trusted` hosts."""
|
|
111
|
+
from starlette.testclient import TestClient
|
|
112
|
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
113
|
+
app = mcp_server.keep_socket_peer(ProxyHeadersMiddleware(mcp_server.build_asgi_app(self.app),
|
|
114
|
+
trusted_hosts=trusted))
|
|
115
|
+
client = TestClient(app, client=(peer, 50000))
|
|
116
|
+
client.__enter__()
|
|
117
|
+
self.addCleanup(client.__exit__, None, None, None)
|
|
118
|
+
return client
|
|
119
|
+
|
|
120
|
+
def test_a_forged_x_forwarded_for_doesn_t_make_a_remote_client_local(self):
|
|
121
|
+
# FORWARDED_ALLOW_IPS=* trusts every peer's X-Forwarded-For: the connection's own address still decides
|
|
122
|
+
res = self.initialize(self.served('203.0.113.9', '*'), {'X-Forwarded-For': '127.0.0.1'})
|
|
123
|
+
self.assertEqual((res.status_code, res.json()['code']), (403, 'open_access_remote'))
|
|
124
|
+
|
|
125
|
+
def test_a_remote_client_sent_on_by_a_proxy_on_this_host_is_refused(self):
|
|
126
|
+
proxy = self.served('127.0.0.1', '127.0.0.1')
|
|
127
|
+
res = self.initialize(proxy, {'X-Forwarded-For': '203.0.113.9'})
|
|
128
|
+
self.assertEqual((res.status_code, res.json()['code']), (403, 'open_access_remote'))
|
|
129
|
+
self.assertEqual(self.initialize(proxy).status_code, 200) # the proxy's own call, or a local one, is answered
|
|
130
|
+
|
|
131
|
+
def test_with_a_key_configured_a_remote_client_with_it_is_let_in(self):
|
|
132
|
+
os.environ['QUERYAPIGATE_API_KEY'] = 'admin-key'
|
|
133
|
+
client = self.http('203.0.113.9')
|
|
134
|
+
self.assertEqual(self.initialize(client, {'X-API-Key': 'admin-key'}).status_code, 200)
|
|
135
|
+
self.assertEqual(self.initialize(client).json()['code'], 'unauthorized') # and without it, the usual 401
|
|
136
|
+
|
|
137
|
+
|
|
62
138
|
class IsReadOnlyTests(unittest.TestCase):
|
|
63
139
|
def test_select_is_read_only(self):
|
|
64
140
|
self.assertTrue(mcp_server.is_read_only({'sql_query': 'SELECT * FROM t'}))
|