queryapigate 0.46.0__tar.gz → 0.48.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.48.0}/PKG-INFO +1 -1
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/alerts.py +4 -4
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/app.py +32 -14
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/cli.py +3 -3
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/config.py +9 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/dataplane.py +56 -4
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/experimental.py +3 -3
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/governance.py +37 -4
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/gunicorn_conf.py +15 -1
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/limits.py +3 -2
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/mcp_server.py +23 -4
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/metrics.py +2 -2
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/ratelimit.py +9 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/sqltools.py +6 -2
- {queryapigate-0.46.0 → queryapigate-0.48.0/queryapigate.egg-info}/PKG-INFO +1 -1
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_cli.py +13 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_dataplane.py +178 -7
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_helm.py +57 -1
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_integration.py +13 -2
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_mcp.py +89 -2
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_queryapigate.py +44 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_quotas.py +12 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_runstore.py +27 -5
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_settings.py +1 -1
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_shared_rate_limit.py +13 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_supply_chain.py +42 -4
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_workspace_isolation.py +24 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/LICENSE +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/NOTICE +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/README.md +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/pyproject.toml +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/accesslog.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/adminroles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/admins.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/apikeys.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/auditstream.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/aws.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/bundle.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/configcode.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AccessMapPage-CvmP9Sfw.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AdministratorsPage-B1G7Dgxv.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AgentsPage-ex_lhxAd.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AlertsPage-CkFfLPBu.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApiKeyDetailPage-DluS3aag.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApiKeysPage-D3xeaQlS.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApprovalsPage-BYL5hXGT.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AuditLogPage-TAMz5LIn.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/CachingPage-8OhOUgxW.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/CodeBox-Cwh5KHLp.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ConnectionsPage-mHkn5I8c.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/DesignerPage-CPZneqCQ.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/DestinationsPage-Dy6NYnv3.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Experimental-B3dnaeV7.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ExportsPage-BzDOR2fh.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/HelpPage-BV_YH8GV.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/HomePage-BShpQIil.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/KeyActivity-DuTyhSGT.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Managed-CQ0VXNNZ.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/McpPage-JMpMLJIz.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/MetricsPage-BOUZqgao.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/QueryInfo-n4U1MkL1.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/RepositoryPage-Cj_ZwMQj.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Results-6QW3JK2B.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/RolesPage-Dq83u-am.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/SchemaBrowser-BzZ6DZjO.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/SettingsPage-CpjtKQ1P.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/StatTile-Cft4kJPH.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Time-DurHvFXj.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/WorkspacesPage-Bfe_wYYe.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/api-CgyYL3VS.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/client-ZHNiMG8H.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/forms-DR_JyNks.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/forms-DdFqq646.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/icons-CFXE8hAz.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/index-0v-rImZs.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/index-Cv64XKNs.css +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/maskRules-CvKQIPgN.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/reach-BAOLd75Y.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/shared-D8E1i8vU.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/useMutation-D4M562eQ.js +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/console_dist/index.html +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/databases.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/db.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/definitions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/destinations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/embeddings.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/engine.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/events.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/exporting.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/exports.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/formats.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/history.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/instances.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/masking.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/metacache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/mongodocs.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/oidc.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/openapi.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/params.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/parquet.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/pool.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/quotas.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/rekey.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/runners.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/runstore.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/schema.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/scope.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/secretrefs.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/access.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/services/workspaces.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/sso.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/store.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/tableguard.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/tracing.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/usage.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/v1.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/v1_spec.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/vault.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate/workspaces.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate.egg-info/SOURCES.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate.egg-info/requires.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/setup.cfg +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_adminroles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_admins.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_alerts.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_api_v1.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_audit_log.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_audit_siem.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_aws.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_broadcast.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_collections.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_configcode.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_console.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_data_protection.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_databases.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_db.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_dependency_floors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_deprecations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_descriptions.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_destinations.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_docs_site.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_embeddings.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_errors.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_events.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_events_server.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_examples.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_experimental.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_export_security.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_exporting.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_exports.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_fips.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_history.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_howto.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_instances.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_jwt.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_limits.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_masking.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_metacache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_mongo_streaming.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_mongotools.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_multinode.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_observability.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_oidc.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_params.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_parquet.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_pool.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_publishing.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_query_window.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_rekey.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_roles.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_rolling_upgrade.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_runstore_differential.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_secretrefs.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_security_headers.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_sql_guard_fuzz.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_sql_guard_writes.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_sso.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_tableguard.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_tracing.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_upgrades.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_usage.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_vault.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_workspace_config_cli.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_workspace_observability.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.0}/tests/test_workspace_store.py +0 -0
- {queryapigate-0.46.0 → queryapigate-0.48.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.48.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
|
|
@@ -44,13 +44,13 @@ _lock = threading.Lock()
|
|
|
44
44
|
_refusals: dict[tuple, deque] = {} # ('key' | 'client', who) -> monotonic times of rate-limit refusals
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def note_rate_limited(kind, who):
|
|
48
|
-
"""
|
|
49
|
-
'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
|
|
47
|
+
def note_rate_limited(kind, who, count=1):
|
|
48
|
+
"""A rate-limit refusal (`count` of them, now), for the 'keeps hitting its limit' alert: kind 'key' (a key's own
|
|
49
|
+
rate_limit) or 'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
|
|
50
50
|
now = time.monotonic()
|
|
51
51
|
with _lock:
|
|
52
52
|
times = _refusals.setdefault((kind, who or 'unknown'), deque(maxlen=1000))
|
|
53
|
-
times.
|
|
53
|
+
times.extend([now] * min(count, 1000))
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
# Every kind of alert, the one list: the OpenAPI enum is built from it and tests/test_alerts.py checks API.md's table.
|
|
@@ -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,10 @@ 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()). The rate-limit buckets
|
|
182
|
+
it counted a call in before forwarding it (X-QAG-Rate-Counted, ADR 0032) are taken from it alone. Its headers are
|
|
183
|
+
removed here, so nothing after this layer reads, logs or passes on the secret."""
|
|
183
184
|
|
|
184
185
|
def __init__(self, app):
|
|
185
186
|
self.app = app
|
|
@@ -188,21 +189,23 @@ class _CapturePeer:
|
|
|
188
189
|
supplied = environ.pop(dataplane.SECRET_ENVIRON, None)
|
|
189
190
|
peer = environ.pop(dataplane.PEER_ENVIRON, None)
|
|
190
191
|
wanted = environ.pop(dataplane.WANTED_ENVIRON, None)
|
|
192
|
+
version = environ.pop(dataplane.VERSION_ENVIRON, None)
|
|
193
|
+
rate_counted = environ.pop(dataplane.COUNTED_ENVIRON, None)
|
|
191
194
|
if supplied is not None and dataplane.matches(supplied):
|
|
192
|
-
|
|
195
|
+
is_paired = dataplane.paired(version)
|
|
196
|
+
environ['queryapigate.dataplane'] = 'plan' if wanted == '1' and is_paired else True
|
|
197
|
+
environ['queryapigate.dataplane_paired'] = is_paired
|
|
198
|
+
if rate_counted is not None and is_paired:
|
|
199
|
+
environ['queryapigate.rate_counted'] = dataplane.counted(rate_counted)
|
|
193
200
|
environ['REMOTE_ADDR'] = peer if dataplane.is_address(peer) else ''
|
|
194
201
|
environ['queryapigate.peer_addr'] = environ.get('REMOTE_ADDR')
|
|
195
202
|
return self.app(environ, start_response)
|
|
196
203
|
|
|
197
204
|
|
|
198
205
|
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
|
|
206
|
+
"""Whether the request's real socket peer is a loopback address (governance.is_loopback(), the MCP server's rule
|
|
207
|
+
too). Fails closed: in open-access mode a caller it can't place must configure a key."""
|
|
208
|
+
return governance.is_loopback(request.environ.get('queryapigate.peer_addr'))
|
|
206
209
|
|
|
207
210
|
|
|
208
211
|
def create_app():
|
|
@@ -362,8 +365,7 @@ def create_app():
|
|
|
362
365
|
# could reach the port would otherwise be an unrestricted admin. The real socket peer decides, never a
|
|
363
366
|
# forgeable header. The public endpoints (health, docs, the Console shell) are reachable either way.
|
|
364
367
|
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')
|
|
368
|
+
raise ApiError(governance.OPEN_ACCESS_REMOTE, 403, code='open_access_remote')
|
|
367
369
|
if g.permission is not None:
|
|
368
370
|
g.permission = settle_workspace(g.permission, named)
|
|
369
371
|
limited = check_key_rate_limit()
|
|
@@ -477,6 +479,10 @@ def check_rate_limit():
|
|
|
477
479
|
"""Count this request against its client's quota (governance.py); returns a 429 response when it is over."""
|
|
478
480
|
if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
479
481
|
return None
|
|
482
|
+
counted = request.environ.get('queryapigate.rate_counted', {}).get('client')
|
|
483
|
+
if counted is not None and config.rate_limit() is not None: # by the data plane, which then forwarded the call
|
|
484
|
+
g.rate_limit = counted
|
|
485
|
+
return None
|
|
480
486
|
verdict = governance.check_client_limit(current_app, request.remote_addr)
|
|
481
487
|
if verdict is None:
|
|
482
488
|
return None
|
|
@@ -490,6 +496,10 @@ def check_key_rate_limit():
|
|
|
490
496
|
per-key identity to limit by before then; a no-op for the admin key and the open/no-key case."""
|
|
491
497
|
if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
492
498
|
return None
|
|
499
|
+
counted = request.environ.get('queryapigate.rate_counted', {}).get('key')
|
|
500
|
+
if counted is not None and getattr(g.get('permission'), 'rate_limit', None) is not None: # as the client's, above
|
|
501
|
+
g.key_rate_limit = counted
|
|
502
|
+
return None
|
|
493
503
|
verdict = governance.check_key_limit(current_app, g.get('permission'))
|
|
494
504
|
if verdict is None:
|
|
495
505
|
return None
|
|
@@ -1238,9 +1248,15 @@ def dataplane_runs():
|
|
|
1238
1248
|
|
|
1239
1249
|
Each run comes with its history entry already JSON (`entry`), and the labels and timings /metrics counts it by: at
|
|
1240
1250
|
the data plane's rates Python's own work per run - encoding it, and its labels - is what it can't keep up with, so
|
|
1241
|
-
a batch is queued at once (history.record_reported()) and counted once per set of labels.
|
|
1251
|
+
a batch is queued at once (history.record_reported()) and counted once per set of labels.
|
|
1252
|
+
|
|
1253
|
+
`rate_limited`: the calls it refused itself with a 429 (ADR 0032) - by bucket, caller and method, one duration
|
|
1254
|
+
each - counted as gate()'s own refusals are: in the rejections, the alerts and the request counters. Not runs."""
|
|
1242
1255
|
report = request.get_json(silent=True) or {}
|
|
1243
1256
|
runs = report.get('runs') or []
|
|
1257
|
+
for refused in report.get('rate_limited') or []:
|
|
1258
|
+
governance.note_refused(refused['scope'], refused.get('client'), refused.get('workspace'), refused.get('key'),
|
|
1259
|
+
refused['method'], refused['seconds'])
|
|
1244
1260
|
broadcaster = current_app.extensions['queryapigate_broadcaster']
|
|
1245
1261
|
with scope.using(scope.current()): # resolved once, not by each of a busy batch's few thousand label lookups
|
|
1246
1262
|
if report.get('recorded'): # in the run store already: written by the data plane itself (ADR 0029 §4)
|
|
@@ -1282,6 +1298,8 @@ def dataplane_lease():
|
|
|
1282
1298
|
there. 404 without a run store."""
|
|
1283
1299
|
if not runstore.enabled():
|
|
1284
1300
|
return jsonify(error_body('No run store is configured', 404)), 404
|
|
1301
|
+
if not request.environ.get('queryapigate.dataplane_paired'):
|
|
1302
|
+
return jsonify(error_body(dataplane.UNPAIRED, 409)), 409
|
|
1285
1303
|
body = request.get_json(silent=True) or {}
|
|
1286
1304
|
holder = body.get('holder')
|
|
1287
1305
|
if not isinstance(holder, str) or not 0 < len(holder) <= 200:
|
|
@@ -127,8 +127,6 @@ def _init(args):
|
|
|
127
127
|
inactive) now that connections are SQLite-backed - no more db_connections.json/saved_sql/ for a new
|
|
128
128
|
install. On a home whose store already has connections, this is a no-op - "already exists - left
|
|
129
129
|
untouched"."""
|
|
130
|
-
home = config.home()
|
|
131
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
132
130
|
if db.connection().execute('SELECT 1 FROM connections LIMIT 1').fetchone() is not None:
|
|
133
131
|
print(f'{db.describe()} already has connections - left untouched')
|
|
134
132
|
return 0
|
|
@@ -903,7 +901,7 @@ def build_parser():
|
|
|
903
901
|
collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
|
|
904
902
|
collection_import.set_defaults(func=_collection_import)
|
|
905
903
|
|
|
906
|
-
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014
|
|
904
|
+
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014): "
|
|
907
905
|
'export it, see what applying one would change, apply it')
|
|
908
906
|
config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
|
|
909
907
|
config_actions = config_parser.add_subparsers(dest='action')
|
|
@@ -1041,6 +1039,8 @@ def main(argv=None):
|
|
|
1041
1039
|
os.environ['QUERYAPIGATE_HOME'] = args.home
|
|
1042
1040
|
if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
|
|
1043
1041
|
return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
|
|
1042
|
+
if getattr(args, 'func', None) is _init: # the one command that makes a home: the store below opens inside it
|
|
1043
|
+
config.home().mkdir(parents=True, exist_ok=True)
|
|
1044
1044
|
try:
|
|
1045
1045
|
config.check_database_url()
|
|
1046
1046
|
db.init_schema()
|
|
@@ -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,20 +15,34 @@ 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.
|
|
24
|
+
|
|
25
|
+
With Redis (QUERYAPIGATE_REDIS_URL), a key's rate limit and the server-wide QUERYAPIGATE_RATE_LIMIT are counted by the
|
|
26
|
+
data plane too, in the same buckets (ADR 0032): the plan carries the key's limit, and the data plane counts a call
|
|
27
|
+
only once it is going to serve it. A call it forwards after counting says so (X-QAG-Rate-Counted), and is not counted
|
|
28
|
+
again here. Its refusals reach /metrics and the alerts with its runs report.
|
|
20
29
|
"""
|
|
21
30
|
import base64
|
|
22
31
|
import hmac
|
|
23
32
|
import ipaddress
|
|
24
33
|
import json
|
|
34
|
+
import logging
|
|
25
35
|
|
|
26
|
-
from . import config, runners, sqltools
|
|
36
|
+
from . import __version__, config, runners, sqltools
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger('queryapigate')
|
|
27
39
|
|
|
28
40
|
SECRET_HEADER = 'X-QAG-Dataplane'
|
|
29
41
|
PEER_HEADER = 'X-QAG-Peer'
|
|
30
42
|
WANTED_HEADER = 'X-QAG-Plan-Wanted'
|
|
43
|
+
VERSION_HEADER = 'X-QAG-Dataplane-Version' # the data plane's release, sent with its secret
|
|
31
44
|
PLAN_HEADER = 'X-QAG-Plan'
|
|
45
|
+
COUNTED_HEADER = 'X-QAG-Rate-Counted' # the rate-limit buckets a call the data plane forwards was counted in already
|
|
32
46
|
VERSION = 1
|
|
33
47
|
DIALECTS = ('postgres', 'mysql') # the two the prototype serves - every other connection is forwarded
|
|
34
48
|
_ROWS = ('api_key', 'saved_query', 'connection') # what a plan names the digests of, one row set each
|
|
@@ -38,6 +52,11 @@ SECRET_ENVIRON = 'HTTP_X_QAG_DATAPLANE' # the two headers as WSGI names them -
|
|
|
38
52
|
PEER_ENVIRON = 'HTTP_X_QAG_PEER'
|
|
39
53
|
# The data plane asks for a plan only when it would keep one: other calls it forwards keep the metadata cache
|
|
40
54
|
WANTED_ENVIRON = 'HTTP_X_QAG_PLAN_WANTED'
|
|
55
|
+
VERSION_ENVIRON = 'HTTP_X_QAG_DATAPLANE_VERSION'
|
|
56
|
+
COUNTED_ENVIRON = 'HTTP_X_QAG_RATE_COUNTED'
|
|
57
|
+
UNPAIRED = ('This data plane is from another release than this server: it gets no plans and no run store lease '
|
|
58
|
+
'until both are the same release')
|
|
59
|
+
_unpaired_seen: set = set()
|
|
41
60
|
|
|
42
61
|
|
|
43
62
|
def matches(supplied):
|
|
@@ -47,6 +66,16 @@ def matches(supplied):
|
|
|
47
66
|
return bool(secret and supplied) and hmac.compare_digest(supplied.encode('utf-8', 'replace'), secret.encode())
|
|
48
67
|
|
|
49
68
|
|
|
69
|
+
def paired(version):
|
|
70
|
+
"""Whether a data plane that sent `version` is from this server's release - logged once per other version."""
|
|
71
|
+
if version == __version__:
|
|
72
|
+
return True
|
|
73
|
+
if version not in _unpaired_seen:
|
|
74
|
+
_unpaired_seen.add(version)
|
|
75
|
+
log.warning('%s (data plane %s, this server %s).', UNPAIRED, version or 'unknown', __version__)
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
50
79
|
def is_address(peer):
|
|
51
80
|
"""Whether X-QAG-Peer names an IP address - the client's socket address, as the data plane saw it."""
|
|
52
81
|
try:
|
|
@@ -58,11 +87,29 @@ def is_address(peer):
|
|
|
58
87
|
|
|
59
88
|
def eligible_caller(permission):
|
|
60
89
|
"""A caller the data plane may serve: a scoped API key - not an administrator, a signed-in user or an open server -
|
|
61
|
-
in the default workspace,
|
|
90
|
+
in the default workspace, without the limits that keep state: no quota or max_concurrent, and a rate limit only
|
|
91
|
+
when Redis holds its bucket, where the data plane counts it too (ADR 0032). Without Redis each process would count
|
|
92
|
+
its own budget. A query_timeout and max_rows keep none: the plan carries them, and the data plane applies each as
|
|
93
|
+
limits.py does."""
|
|
62
94
|
return (permission is not None and not permission.admin and permission.claims is None and permission.role is None
|
|
63
95
|
and not permission.claim_binding and permission.workspace == 'default'
|
|
64
|
-
and permission.rate_limit is None
|
|
65
|
-
and permission.
|
|
96
|
+
and (permission.rate_limit is None or config.redis_url() is not None)
|
|
97
|
+
and permission.quota is None and permission.max_concurrent is None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def counted(value):
|
|
101
|
+
"""X-QAG-Rate-Counted (`client=<limit>,<remaining>; key=<limit>,<remaining>`, either or both) as
|
|
102
|
+
{'client' | 'key': (limit, remaining)}: the buckets the data plane counted this call in before forwarding it, and
|
|
103
|
+
what they said. {} for a value of any other shape."""
|
|
104
|
+
found = {}
|
|
105
|
+
for part in (value or '').split(';'):
|
|
106
|
+
bucket, _, numbers = part.strip().partition('=')
|
|
107
|
+
limit, _, remaining = numbers.partition(',')
|
|
108
|
+
if bucket not in ('client', 'key') or bucket in found or not (limit.isascii() and limit.isdigit()
|
|
109
|
+
and remaining.isascii() and remaining.isdigit()):
|
|
110
|
+
return {}
|
|
111
|
+
found[bucket] = (int(limit), int(remaining))
|
|
112
|
+
return found
|
|
66
113
|
|
|
67
114
|
|
|
68
115
|
def digests(record):
|
|
@@ -139,6 +186,11 @@ def build(record, permission, query_name, number, saved, connection_name, result
|
|
|
139
186
|
return None
|
|
140
187
|
mask = {'columns': len(masked.traced), 'actions': masked.actions, 'fired': masked.fired}
|
|
141
188
|
return {'v': VERSION, 'rows': rows, 'key': {'workspace': permission.workspace, 'name': permission.name},
|
|
189
|
+
# (count, period in seconds): counted by the data plane in Redis, in the bucket Python counts it in
|
|
190
|
+
'rate_limit': list(permission.rate_limit) if permission.rate_limit else None,
|
|
191
|
+
# the key's own limits (ADR 0011), applied as limits.timeout() and limits.rows() apply them; an int
|
|
192
|
+
# query_timeout stays one, as min() keeps it
|
|
193
|
+
'query_timeout': permission.query_timeout, 'max_rows': permission.max_rows,
|
|
142
194
|
'query': {'name': query_name, 'version': number}, 'connection': {'name': connection_name,
|
|
143
195
|
'dialect': dialect}, 'statement': sent, 'search_path': prepared['search_path'], 'mask': mask,
|
|
144
196
|
'expires_in': expires_in,
|
|
@@ -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,9 +10,13 @@ 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
|
-
from . import admins, alerts, apikeys, config, jwtauth, metrics
|
|
16
|
+
from . import admins, alerts, apikeys, config, jwtauth, metrics, ratelimit
|
|
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.')
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
@dataclass(frozen=True)
|
|
@@ -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 '
|
|
@@ -78,13 +98,26 @@ def check_key_limit(flask_app, permission):
|
|
|
78
98
|
return None
|
|
79
99
|
count, period = permission.rate_limit
|
|
80
100
|
allowed, remaining, retry_after = flask_app.extensions['queryapigate_key_limiter'].hit(
|
|
81
|
-
permission
|
|
101
|
+
ratelimit.budget_name(permission), count, period)
|
|
82
102
|
if not allowed:
|
|
83
103
|
metrics.inc_rate_limit_rejection()
|
|
84
104
|
alerts.note_rate_limited('key', (getattr(permission, 'workspace', 'default'), permission.name))
|
|
85
105
|
return Verdict(allowed, count, remaining, retry_after)
|
|
86
106
|
|
|
87
107
|
|
|
108
|
+
def note_refused(scope, client_ip, workspace, key, method, elapsed):
|
|
109
|
+
"""429s the data plane answered itself (ADR 0032), one duration each: counted as check_client_limit() and
|
|
110
|
+
check_key_limit() count their own - the rejections and the alerts - and in the request counters, as the calls they
|
|
111
|
+
were. `scope` is 'client' (by `client_ip`, before the caller is known: no key label) or 'key'."""
|
|
112
|
+
if scope == 'client':
|
|
113
|
+
who, label = client_ip, '-'
|
|
114
|
+
else:
|
|
115
|
+
who, label = (workspace or 'default', key), key or '-'
|
|
116
|
+
metrics.inc_rate_limit_rejection(len(elapsed))
|
|
117
|
+
alerts.note_rate_limited(scope, who, len(elapsed))
|
|
118
|
+
metrics.observe_requests(method, 'api.run_named_query', '429', elapsed, label)
|
|
119
|
+
|
|
120
|
+
|
|
88
121
|
def observe(method, endpoint, status, elapsed, key):
|
|
89
122
|
"""One finished call in /metrics' request counters and latency histograms. REST passes the HTTP method and its
|
|
90
123
|
route's endpoint; MCP passes method 'MCP' and an 'mcp.*' endpoint, so the two can be told apart."""
|
|
@@ -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 = ''
|
|
@@ -229,10 +229,11 @@ def acquire(query_timeout=None):
|
|
|
229
229
|
lease = (query_timeout or LEASE_WITHOUT_TIMEOUT) + LEASE_MARGIN
|
|
230
230
|
token = secrets.token_hex(8)
|
|
231
231
|
store = counter()
|
|
232
|
-
|
|
232
|
+
name = ratelimit.budget_name(permission)
|
|
233
|
+
if not store.acquire(name, maximum, token, lease):
|
|
233
234
|
raise ApiError(f'This caller already has {maximum} queries running, its max_concurrent - retry when one '
|
|
234
235
|
'finishes', 429, code='too_many_concurrent', retry_after=1, max_concurrent=maximum)
|
|
235
|
-
return Slot(
|
|
236
|
+
return Slot(name, token, lease, store)
|
|
236
237
|
|
|
237
238
|
|
|
238
239
|
def effective(grants):
|
|
@@ -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
|
|
@@ -343,7 +344,9 @@ def call_tool_for(flask_app, permission, name, arguments):
|
|
|
343
344
|
structured = g.agent_write_result
|
|
344
345
|
return {'content': [{'type': 'text', 'text': json.dumps(structured, default=str)}],
|
|
345
346
|
'structuredContent': structured}
|
|
346
|
-
|
|
347
|
+
# REST answers no rows with {"message": "No results returned"}; a tool's rows are a list (its outputSchema)
|
|
348
|
+
rows = response.get_json() if response.headers.get('X-Row-Count') != '0' else []
|
|
349
|
+
structured = {'rows': rows, 'truncated': response.headers.get('X-Has-More') == 'true'}
|
|
347
350
|
return _rows_result(structured)
|
|
348
351
|
|
|
349
352
|
|
|
@@ -604,6 +607,17 @@ def permission_for_listing(flask_app, api_key, authorization, client_ip):
|
|
|
604
607
|
|
|
605
608
|
|
|
606
609
|
PERMISSION_STATE = 'queryapigate_permission' # where the gate leaves the caller, in the ASGI scope's state
|
|
610
|
+
SOCKET_PEER = 'queryapigate.socket_peer' # the connection's own address, kept before X-Forwarded-For can replace it
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def keep_socket_peer(app):
|
|
614
|
+
"""Record each request's real socket peer in its scope, for the gate's open-access rule, before the proxy-header
|
|
615
|
+
middleware inside it rewrites `client` from X-Forwarded-For (run())."""
|
|
616
|
+
async def wrapped(scope, receive, send):
|
|
617
|
+
if scope['type'] == 'http' and scope.get('client'):
|
|
618
|
+
scope[SOCKET_PEER] = scope['client'][0]
|
|
619
|
+
return await app(scope, receive, send)
|
|
620
|
+
return wrapped
|
|
607
621
|
|
|
608
622
|
|
|
609
623
|
def resource_metadata_url():
|
|
@@ -659,7 +673,8 @@ def gate(flask_app, inner):
|
|
|
659
673
|
headers={'Retry-After': str(verdict.retry_after), 'X-Request-Id': request_id})
|
|
660
674
|
else:
|
|
661
675
|
api_key, authorization = headers.get('x-api-key', ''), headers.get('authorization', '')
|
|
662
|
-
permission, refusal = governance.authenticate_mcp(api_key, authorization, client_ip
|
|
676
|
+
permission, refusal = governance.authenticate_mcp(api_key, authorization, client_ip,
|
|
677
|
+
peer=scope.get(SOCKET_PEER))
|
|
663
678
|
if permission is not None:
|
|
664
679
|
scope.setdefault('state', {})[PERMISSION_STATE] = permission
|
|
665
680
|
return await inner(scope, receive, send)
|
|
@@ -784,7 +799,11 @@ def build_asgi_app(flask_app):
|
|
|
784
799
|
|
|
785
800
|
|
|
786
801
|
def run(flask_app, host, port):
|
|
787
|
-
"""Serves build_asgi_app() until interrupted.
|
|
802
|
+
"""Serves build_asgi_app() until interrupted. Uvicorn's own proxy-header handling is applied here instead, with
|
|
803
|
+
the same trusted hosts (FORWARDED_ALLOW_IPS, else loopback), so the socket peer can be kept before it."""
|
|
788
804
|
import uvicorn
|
|
805
|
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
789
806
|
|
|
790
|
-
|
|
807
|
+
trusted = os.environ.get('FORWARDED_ALLOW_IPS', '127.0.0.1,::1') # uvicorn.Config's own default
|
|
808
|
+
app = keep_socket_peer(ProxyHeadersMiddleware(build_asgi_app(flask_app), trusted_hosts=trusted))
|
|
809
|
+
uvicorn.run(app, host=host, port=port, server_header=False, proxy_headers=False) # no version banner
|
|
@@ -180,10 +180,10 @@ def dec_active_query():
|
|
|
180
180
|
_active_queries -= 1
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def inc_rate_limit_rejection():
|
|
183
|
+
def inc_rate_limit_rejection(count=1):
|
|
184
184
|
global _rate_limit_rejections
|
|
185
185
|
with _lock:
|
|
186
|
-
_rate_limit_rejections +=
|
|
186
|
+
_rate_limit_rejections += count
|
|
187
187
|
|
|
188
188
|
|
|
189
189
|
def inc_rate_limit_fallback():
|
|
@@ -120,6 +120,15 @@ end
|
|
|
120
120
|
return {allowed, math.floor(tokens), retry}
|
|
121
121
|
"""
|
|
122
122
|
_PREFIX = 'qag:ratelimit:'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def budget_name(permission):
|
|
126
|
+
"""What a caller's own budgets - its rate limit's bucket, its max_concurrent slots (limits.py) - are counted
|
|
127
|
+
under: `<workspace>~<name>`, as quotas count theirs. A name is unique only within its workspace, so two workspaces'
|
|
128
|
+
keys of one name are two callers; no workspace name has a `~`."""
|
|
129
|
+
return f"{getattr(permission, 'workspace', None) or 'default'}~{permission.name}"
|
|
130
|
+
|
|
131
|
+
|
|
123
132
|
_DEGRADED_FOR = 600 # seconds a Redis failure keeps the "limits are per instance right now" alert up
|
|
124
133
|
_degraded_at = None
|
|
125
134
|
_last_warned = 0.0
|
|
@@ -270,7 +270,11 @@ def paginate(sql, limit, offset, params=None, dialect=None):
|
|
|
270
270
|
# Parameters
|
|
271
271
|
# --------------------------------------------------------------------------------------
|
|
272
272
|
|
|
273
|
-
def _check_value(name, value):
|
|
273
|
+
def _check_value(name, value, dialect=None):
|
|
274
|
+
if isinstance(value, str) and dialect == 'postgres' and '\x00' in value:
|
|
275
|
+
# psycopg2 refuses it ("A string literal cannot contain NUL") - the caller's value, not the query, is wrong
|
|
276
|
+
raise ApiError(f"Parameter '{name}' contains a NUL character, which PostgreSQL text can't hold",
|
|
277
|
+
code='param_invalid')
|
|
274
278
|
if value is None or isinstance(value, (bool, str)):
|
|
275
279
|
return
|
|
276
280
|
if isinstance(value, (int, float)) and math.isfinite(value):
|
|
@@ -344,7 +348,7 @@ def bind_parameters(sql, params, style, dialect=None):
|
|
|
344
348
|
if missing:
|
|
345
349
|
raise ApiError(f"No value provided for parameter(s): {', '.join(missing)}", code='param_required')
|
|
346
350
|
for name in set(names):
|
|
347
|
-
_check_value(name, params[name])
|
|
351
|
+
_check_value(name, params[name], dialect)
|
|
348
352
|
|
|
349
353
|
escape = (lambda text: text.replace('%', '%%')) if style in ('format', 'pyformat') else (lambda text: text)
|
|
350
354
|
pieces, position = [], 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.48.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
|
|
@@ -199,5 +199,18 @@ class LegacySettingsTests(unittest.TestCase):
|
|
|
199
199
|
self.assertEqual(config.api_key(), 'secret')
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
class InitTests(unittest.TestCase):
|
|
203
|
+
def test_init_makes_a_home_that_doesn_t_exist_yet(self):
|
|
204
|
+
tmp = tempfile.TemporaryDirectory()
|
|
205
|
+
self.addCleanup(tmp.cleanup)
|
|
206
|
+
home = os.path.join(tmp.name, 'new', 'home')
|
|
207
|
+
out = io.StringIO()
|
|
208
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_HOME': home}), redirect_stdout(out):
|
|
209
|
+
self.assertEqual(cli.main(['init']), 0)
|
|
210
|
+
self.assertTrue(store.read_connections()) # the templates, in the store it made
|
|
211
|
+
self.assertTrue(os.path.isdir(home))
|
|
212
|
+
self.assertIn('Created', out.getvalue())
|
|
213
|
+
|
|
214
|
+
|
|
202
215
|
if __name__ == '__main__':
|
|
203
216
|
unittest.main()
|