queryapigate 0.26.0__tar.gz → 0.28.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.26.0/queryapigate.egg-info → queryapigate-0.28.0}/PKG-INFO +4 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/pyproject.toml +5 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/adminroles.py +3 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/alerts.py +22 -2
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/apikeys.py +72 -32
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/app.py +27 -0
- queryapigate-0.28.0/queryapigate/aws.py +85 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/cli.py +169 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/config.py +247 -0
- queryapigate-0.28.0/queryapigate/configcode.py +1034 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/AccessMapPage-rRSiv2mm.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AccessMapPage-DIu936ZH.js +3 -3
- queryapigate-0.26.0/queryapigate/console_dist/assets/AdministratorsPage-DoeYjW1i.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AdministratorsPage-B2IYmP_x.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/AgentsPage-DerF6ZBb.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AgentsPage-toV9AXCc.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/AlertsPage-BWROcwNn.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AlertsPage-CzC-qhMh.js +1 -1
- queryapigate-0.28.0/queryapigate/console_dist/assets/ApiKeysPage-C0VW5NrS.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/ApprovalsPage-DSKisl9D.js → queryapigate-0.28.0/queryapigate/console_dist/assets/ApprovalsPage-eyGXo0ky.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/AuditLogPage-Etti--Y7.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AuditLogPage-GdbYovis.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/CachingPage-BZ61rPfT.js → queryapigate-0.28.0/queryapigate/console_dist/assets/CachingPage-4stBdbWQ.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/CodeBox-GGv4fKoR.js → queryapigate-0.28.0/queryapigate/console_dist/assets/CodeBox-DRfv6yXf.js +1 -1
- queryapigate-0.28.0/queryapigate/console_dist/assets/ConnectionsPage-XYubobx_.js +4 -0
- queryapigate-0.28.0/queryapigate/console_dist/assets/DesignerPage-B1Sc83US.js +4 -0
- queryapigate-0.28.0/queryapigate/console_dist/assets/DestinationsPage-C30f018_.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/Experimental-BB0g6F3s.js → queryapigate-0.28.0/queryapigate/console_dist/assets/Experimental-BD3aGuwW.js +1 -1
- queryapigate-0.28.0/queryapigate/console_dist/assets/ExportsPage-BMRj3VOx.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/HelpPage-CUTTBoH6.js → queryapigate-0.28.0/queryapigate/console_dist/assets/HelpPage-DCkoTqa5.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/HomePage-DtOzlurj.js → queryapigate-0.28.0/queryapigate/console_dist/assets/HomePage-CcpGDBK_.js +3 -3
- queryapigate-0.28.0/queryapigate/console_dist/assets/Managed-CNWDZi6e.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/MetricsPage-o3kSXmOq.js → queryapigate-0.28.0/queryapigate/console_dist/assets/MetricsPage-Dxdebop4.js +1 -1
- queryapigate-0.28.0/queryapigate/console_dist/assets/RepositoryPage-DmazL70u.js +4 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/Results-xBStmWdp.js → queryapigate-0.28.0/queryapigate/console_dist/assets/Results-BdoTFMC_.js +2 -2
- queryapigate-0.28.0/queryapigate/console_dist/assets/RolesPage-Dn-c3szF.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/SchemaBrowser-DykbAObL.js → queryapigate-0.28.0/queryapigate/console_dist/assets/SchemaBrowser-mmFZRlm_.js +4 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/SettingsPage-mw196Ecu.js → queryapigate-0.28.0/queryapigate/console_dist/assets/SettingsPage-Bi6cE77i.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/StatTile-5XA5PKWp.js → queryapigate-0.28.0/queryapigate/console_dist/assets/StatTile-DnMgkKp4.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/Time-zMnS1kSm.js → queryapigate-0.28.0/queryapigate/console_dist/assets/Time-CZQinVk_.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/forms-Cl7JC3vt.js → queryapigate-0.28.0/queryapigate/console_dist/assets/forms-BlZHx8-q.js +2 -2
- queryapigate-0.28.0/queryapigate/console_dist/assets/forms-qgM6Rab7.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/icons-B_wbfO4Z.js → queryapigate-0.28.0/queryapigate/console_dist/assets/icons-CDledy67.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/index-D9oV4vVP.css → queryapigate-0.28.0/queryapigate/console_dist/assets/index-BLd0t6vZ.css +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/index-D0zbyWWM.js → queryapigate-0.28.0/queryapigate/console_dist/assets/index-C7ega31V.js +4 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/maskRules-Cw-l_UG8.js → queryapigate-0.28.0/queryapigate/console_dist/assets/maskRules-D7QV7oGO.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/reach-DFqBXYli.js → queryapigate-0.28.0/queryapigate/console_dist/assets/reach-C1r9_MqV.js +1 -1
- queryapigate-0.28.0/queryapigate/console_dist/assets/shared-DLUzWeB2.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/useMutation-BCy3Br67.js → queryapigate-0.28.0/queryapigate/console_dist/assets/useMutation-Df0q777p.js +1 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console_dist/index.html +2 -2
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/db.py +82 -4
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/experimental.py +17 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/exports.py +19 -8
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/history.py +2 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/pool.py +16 -5
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/runners.py +14 -5
- queryapigate-0.28.0/queryapigate/secretrefs.py +548 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/store.py +52 -41
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/v1.py +58 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/v1_spec.py +78 -0
- queryapigate-0.28.0/queryapigate/vault.py +172 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0/queryapigate.egg-info}/PKG-INFO +4 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/SOURCES.txt +43 -34
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/requires.txt +4 -0
- queryapigate-0.28.0/tests/test_aws.py +213 -0
- queryapigate-0.28.0/tests/test_configcode.py +924 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_helm.py +49 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_history.py +18 -3
- queryapigate-0.28.0/tests/test_secretrefs.py +270 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_settings.py +1 -1
- queryapigate-0.28.0/tests/test_vault.py +406 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/ApiKeysPage-DxEySu_Z.js +0 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/ConnectionsPage-CFCKdJ9-.js +0 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/DesignerPage-DL1ysNb9.js +0 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/DestinationsPage-Du2l56ln.js +0 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/ExportsPage-Cs2BpNim.js +0 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/RepositoryPage-Cooj3jby.js +0 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/RolesPage-CcR_ttpB.js +0 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/forms-C5L_fKDm.js +0 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/shared-DygTbsGs.js +0 -1
- {queryapigate-0.26.0 → queryapigate-0.28.0}/LICENSE +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/README.md +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/admins.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/auditstream.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/bundle.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/databases.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/definitions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/destinations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/engine.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/events.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/exporting.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/formats.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/governance.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/instances.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/limits.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/masking.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/mcp_server.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/metrics.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/oidc.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/openapi.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/params.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/parquet.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/quotas.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/ratelimit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/rekey.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/schema.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/access.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/sqltools.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/sso.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/tableguard.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/tracing.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/usage.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/setup.cfg +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_adminroles.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_admins.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_alerts.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_api_v1.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_audit_log.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_broadcast.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_cli.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_collections.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_console.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_databases.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_db.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_dependency_floors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_deprecations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_descriptions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_destinations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_errors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_events.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_events_server.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_examples.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_experimental.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_export_security.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_exporting.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_exports.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_howto.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_instances.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_integration.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_jwt.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_limits.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_masking.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mcp.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mongotools.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_multinode.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_observability.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_oidc.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_params.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_parquet.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_pool.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_publishing.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_query_window.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_queryapigate.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_quotas.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_rekey.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_roles.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_rolling_upgrade.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_security_headers.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_shared_rate_limit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sql_guard_fuzz.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sql_guard_writes.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sso.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_tableguard.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_tracing.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_upgrades.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.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: FSL-1.1-MIT
|
|
@@ -58,12 +58,15 @@ Requires-Dist: opentelemetry-sdk>=1.27; extra == "otel"
|
|
|
58
58
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27; extra == "otel"
|
|
59
59
|
Provides-Extra: server
|
|
60
60
|
Requires-Dist: gunicorn>=21; extra == "server"
|
|
61
|
+
Provides-Extra: aws
|
|
62
|
+
Requires-Dist: boto3>=1.28; extra == "aws"
|
|
61
63
|
Provides-Extra: docs
|
|
62
64
|
Requires-Dist: mkdocs-material<10,>=9; extra == "docs"
|
|
63
65
|
Provides-Extra: dev
|
|
64
66
|
Requires-Dist: queryapigate[all]; extra == "dev"
|
|
65
67
|
Requires-Dist: queryapigate[redis]; extra == "dev"
|
|
66
68
|
Requires-Dist: queryapigate[otel]; extra == "dev"
|
|
69
|
+
Requires-Dist: queryapigate[aws]; extra == "dev"
|
|
67
70
|
Requires-Dist: queryapigate[mcp]; python_version >= "3.10" and extra == "dev"
|
|
68
71
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
69
72
|
Requires-Dist: openapi-spec-validator>=0.7; extra == "dev"
|
|
@@ -48,8 +48,12 @@ all = ["queryapigate[mysql,postgres,clickhouse,h2,duckdb,mongo,flow,encryption,j
|
|
|
48
48
|
# only needed when an OTLP endpoint is set (OTEL_EXPORTER_OTLP_ENDPOINT) - see tracing.py
|
|
49
49
|
otel = ["opentelemetry-sdk>=1.27", "opentelemetry-exporter-otlp-proto-http>=1.27"]
|
|
50
50
|
server = ["gunicorn>=21"]
|
|
51
|
+
# only needed for ${aws-sm:...} and aws-rds-iam credentials (ADR 0015) - see aws.py. 1.28 is the floor because that is
|
|
52
|
+
# where boto3 reads AWS_ENDPOINT_URL_<SERVICE> (a VPC endpoint, or a local stand-in).
|
|
53
|
+
aws = ["boto3>=1.28"]
|
|
51
54
|
docs = ["mkdocs-material>=9,<10"]
|
|
52
|
-
dev = ["queryapigate[all]", "queryapigate[redis]", "queryapigate[otel]", "queryapigate[
|
|
55
|
+
dev = ["queryapigate[all]", "queryapigate[redis]", "queryapigate[otel]", "queryapigate[aws]",
|
|
56
|
+
"queryapigate[mcp]; python_version>='3.10'",
|
|
53
57
|
"ruff>=0.5",
|
|
54
58
|
"openapi-spec-validator>=0.7", "hypothesis>=6.100", "mypy>=1.10", "coverage>=7.4"]
|
|
55
59
|
|
|
@@ -33,12 +33,13 @@ CAPABILITIES = {
|
|
|
33
33
|
'exports.write': 'create, change and delete exports',
|
|
34
34
|
'exports.run': 'run an export',
|
|
35
35
|
'destinations.write': 'create, change, test and delete destinations',
|
|
36
|
+
'config.apply': 'see what a configuration file would change, and apply it - any of the above (ADR 0014)',
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
_EVERYONE = {'queries.read', 'connections.read', 'observe', 'self'}
|
|
39
40
|
ROLES = {
|
|
40
41
|
'owner': set(CAPABILITIES),
|
|
41
|
-
'admin': set(CAPABILITIES) - {'admins.read', 'admins.write', 'secrets.rotate'},
|
|
42
|
+
'admin': set(CAPABILITIES) - {'admins.read', 'admins.write', 'secrets.rotate', 'config.apply'},
|
|
42
43
|
'developer': _EVERYONE | {'queries.write', 'exports.read', 'exports.run'},
|
|
43
44
|
'auditor': _EVERYONE | {'access.read', 'settings.read', 'exports.read'},
|
|
44
45
|
}
|
|
@@ -90,6 +91,7 @@ OPERATIONS = {
|
|
|
90
91
|
'v1.list_exports': 'exports.read', 'v1.get_export': 'exports.read', 'v1.list_export_runs': 'exports.read',
|
|
91
92
|
'v1.create_export': 'exports.write', 'v1.update_export': 'exports.write', 'v1.delete_export': 'exports.write',
|
|
92
93
|
'v1.run_export': 'exports.run',
|
|
94
|
+
'v1.config_diff': 'config.apply', 'v1.config_apply': 'config.apply', 'v1.config_managed': 'observe',
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
|
|
@@ -58,7 +58,7 @@ KINDS = ('open_server', 'break_glass_used', 'connection_failing', 'key_expired',
|
|
|
58
58
|
'query_timeouts', 'query_slow', 'key_rate_limited', 'client_rate_limited', 'history_failed', 'history_dropped',
|
|
59
59
|
'instances_not_shared', 'instances_versions_differ', 'rate_limits_not_shared', 'export_failing', 'key_unused',
|
|
60
60
|
'agent_unusual_volume', 'sso_unreachable', 'writes_awaiting_approval', 'encryption_key_rotation_incomplete',
|
|
61
|
-
'quota_nearly_used', 'audit_chain_broken', 'audit_delivery_behind')
|
|
61
|
+
'quota_nearly_used', 'audit_chain_broken', 'audit_delivery_behind', 'secret_provider_failing')
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def _alert(severity, kind, subject, title, detail, target=None, since=None):
|
|
@@ -75,7 +75,8 @@ def _plural(n, word):
|
|
|
75
75
|
def collect(now=None):
|
|
76
76
|
"""Every alert that holds now, most severe first."""
|
|
77
77
|
now = now or datetime.now()
|
|
78
|
-
found = [*_server(now), *_sso(), *_encryption(), *
|
|
78
|
+
found = [*_server(now), *_sso(), *_encryption(), *_secret_providers(), *_audit_chain(), *_audit_delivery(now),
|
|
79
|
+
*_writes(),
|
|
79
80
|
*_quotas(), *_keys(now), *_runs(now), *_rate_limited(), *_history_health()]
|
|
80
81
|
return sorted(found, key=lambda a: (SEVERITY_ORDER[a['severity']], a['kind'], a['id']))
|
|
81
82
|
|
|
@@ -120,6 +121,25 @@ def _encryption():
|
|
|
120
121
|
'the first. Remove the others from every instance to finish the rotation.', target)]
|
|
121
122
|
|
|
122
123
|
|
|
124
|
+
def _secret_providers():
|
|
125
|
+
"""A secret provider - a file, Vault, AWS - failing for five minutes in this process (ADR 0015): connections whose
|
|
126
|
+
secrets come from it work only while the values it gave before are within their time, then fail."""
|
|
127
|
+
import time
|
|
128
|
+
|
|
129
|
+
from . import secretrefs
|
|
130
|
+
found = []
|
|
131
|
+
for name, status in sorted(secretrefs.cache.status.items()):
|
|
132
|
+
since = status.get('failing_since')
|
|
133
|
+
if since is None or time.time() - since < 300:
|
|
134
|
+
continue
|
|
135
|
+
stamp = f'{datetime.fromtimestamp(since):%Y-%m-%d %H:%M:%S}'
|
|
136
|
+
found.append(_alert('critical', 'secret_provider_failing', name, f'The secret provider {name} is failing',
|
|
137
|
+
f"It has failed since {stamp}: {status.get('last_error')}. Connections that take a secret "
|
|
138
|
+
'from it work only while what it gave before is within its time; then they fail with '
|
|
139
|
+
'secret_unavailable.', {'type': 'settings', 'name': 'security'}, stamp))
|
|
140
|
+
return found
|
|
141
|
+
|
|
142
|
+
|
|
123
143
|
def _audit_chain():
|
|
124
144
|
"""The audit log's hash chain doesn't verify (ADR 0012): an entry changed, removed, inserted or reordered."""
|
|
125
145
|
from . import auditchain
|
|
@@ -417,6 +417,29 @@ def _validate_allowed_tables(allowed_tables):
|
|
|
417
417
|
return sorted({v.lower() for v in allowed_tables})
|
|
418
418
|
|
|
419
419
|
|
|
420
|
+
def normalize_grants(connections=None, allow_writes=False, queries=None, collections=None, rate_limit=None,
|
|
421
|
+
allowed_ips=None, allowed_write_ops=None, allowed_tables=None, mask=None, max_concurrent=None,
|
|
422
|
+
query_timeout=None, max_rows=None, quota=None):
|
|
423
|
+
"""A key's or role's grants, checked and normalised as they're stored - without writing anything, or checking that
|
|
424
|
+
the collections named exist (_require_existing_collections() does, where they're saved). The one place a grant's
|
|
425
|
+
rules are applied: creating a key or role, and reading one from a configuration file (configcode.py)."""
|
|
426
|
+
return {
|
|
427
|
+
'connections': _normalize_connections(connections),
|
|
428
|
+
'allow_writes': bool(allow_writes),
|
|
429
|
+
'queries': _normalize_queries(queries),
|
|
430
|
+
'collections': _normalize_collections(collections),
|
|
431
|
+
'rate_limit': _validate_rate_limit(rate_limit),
|
|
432
|
+
'allowed_ips': _validate_allowed_ips(allowed_ips),
|
|
433
|
+
'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
|
|
434
|
+
'allowed_tables': _validate_allowed_tables(allowed_tables),
|
|
435
|
+
'mask': masking.validate_rules(mask),
|
|
436
|
+
'max_concurrent': _validate_limit('max_concurrent', max_concurrent),
|
|
437
|
+
'query_timeout': _validate_limit('query_timeout', query_timeout),
|
|
438
|
+
'max_rows': _validate_limit('max_rows', max_rows),
|
|
439
|
+
'quota': quotas.validate(quota),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
420
443
|
def is_expired(entry):
|
|
421
444
|
"""Whether a stored key entry's expires_at date has passed - checked live on every authenticate() call,
|
|
422
445
|
not swept by a background job: an expired key simply stops matching, the same way flipping `active` to
|
|
@@ -476,6 +499,13 @@ def list_keys():
|
|
|
476
499
|
for name, entry in _read().items()}
|
|
477
500
|
|
|
478
501
|
|
|
502
|
+
def secret_hashes():
|
|
503
|
+
"""{name: SHA-256 of the key's secret} - for a configuration file only (ADR 0014), which declares a key by its
|
|
504
|
+
hash. Not a secret: a generated secret is 32 random bytes, so its hash can't be reversed. The API never shows it
|
|
505
|
+
anyway, like the rest of what authenticates a key."""
|
|
506
|
+
return {name: entry['hash'] for name, entry in _read().items()}
|
|
507
|
+
|
|
508
|
+
|
|
479
509
|
def create_key(name, connections=None, allow_writes=None, queries=None, expires_at=None, rate_limit=None,
|
|
480
510
|
allowed_ips=None, allowed_write_ops=None, role=None, collections=None, allowed_tables=None,
|
|
481
511
|
example=False, mask=None, max_concurrent=None, query_timeout=None, max_rows=None, quota=None):
|
|
@@ -517,20 +547,10 @@ def create_key(name, connections=None, allow_writes=None, queries=None, expires_
|
|
|
517
547
|
raise ApiError(f"An API key named '{name}' already exists", 409, code='key_exists')
|
|
518
548
|
entry = {
|
|
519
549
|
'hash': _hash(secret),
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
'collections': collections,
|
|
550
|
+
**normalize_grants(connections, allow_writes, queries, collections, rate_limit, allowed_ips,
|
|
551
|
+
allowed_write_ops, allowed_tables, mask, max_concurrent, query_timeout, max_rows,
|
|
552
|
+
quota),
|
|
524
553
|
'expires_at': _validate_expiry(expires_at),
|
|
525
|
-
'rate_limit': _validate_rate_limit(rate_limit),
|
|
526
|
-
'allowed_ips': _validate_allowed_ips(allowed_ips),
|
|
527
|
-
'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
|
|
528
|
-
'allowed_tables': _validate_allowed_tables(allowed_tables),
|
|
529
|
-
'mask': masking.validate_rules(mask),
|
|
530
|
-
'max_concurrent': _validate_limit('max_concurrent', max_concurrent),
|
|
531
|
-
'query_timeout': _validate_limit('query_timeout', query_timeout),
|
|
532
|
-
'max_rows': _validate_limit('max_rows', max_rows),
|
|
533
|
-
'quota': quotas.validate(quota),
|
|
534
554
|
'created_from_role': role,
|
|
535
555
|
'active': True,
|
|
536
556
|
'created_at': store.now(),
|
|
@@ -610,15 +630,45 @@ def rotate_key(name, grace_minutes=None):
|
|
|
610
630
|
'- end that first (DELETE .../previous), or wait for it', 409,
|
|
611
631
|
code='key_rotation_pending', previous_expires_at=row['previous_expires_at'])
|
|
612
632
|
secret = 'sk_' + secrets.token_urlsafe(32)
|
|
613
|
-
until = (
|
|
614
|
-
details = json.loads(row['details_json'])
|
|
615
|
-
details.pop('previous_last_used_at', None)
|
|
616
|
-
conn.execute('UPDATE api_keys SET hash = ?, previous_hash = ?, previous_expires_at = ?, details_json = ? '
|
|
617
|
-
'WHERE name = ?', (_hash(secret), row['hash'] if until else None, until, json.dumps(details),
|
|
618
|
-
name))
|
|
633
|
+
until = _replace_hash(conn, name, row, _hash(secret), grace)
|
|
619
634
|
return secret, until
|
|
620
635
|
|
|
621
636
|
|
|
637
|
+
def _replace_hash(conn, name, row, new_hash, grace):
|
|
638
|
+
"""The key's secret becomes the one `new_hash` is the hash of; the old one keeps working `grace` minutes."""
|
|
639
|
+
until = (datetime.now() + timedelta(minutes=grace)).strftime('%Y-%m-%d %H:%M:%S') if grace else None
|
|
640
|
+
details = json.loads(row['details_json'])
|
|
641
|
+
details.pop('previous_last_used_at', None)
|
|
642
|
+
conn.execute('UPDATE api_keys SET hash = ?, previous_hash = ?, previous_expires_at = ?, details_json = ? '
|
|
643
|
+
'WHERE name = ?', (new_hash, row['hash'] if until else None, until, json.dumps(details), name))
|
|
644
|
+
return until
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def set_secret_hash(name, secret_hash, grace_minutes=None):
|
|
648
|
+
"""A key's secret set to the one `secret_hash` is the SHA-256 of - a configuration file's `secret_sha256`
|
|
649
|
+
(ADR 0014) - with the old secret kept working for `grace_minutes`, as rotate_key() does. The secret itself is
|
|
650
|
+
never seen here. Refused when another key already has it: one secret must name one key. Returns when the old
|
|
651
|
+
secret stops working."""
|
|
652
|
+
grace = _validate_grace(grace_minutes)
|
|
653
|
+
with db.transaction() as conn:
|
|
654
|
+
row = conn.execute('SELECT hash, details_json, previous_hash, previous_expires_at FROM api_keys WHERE name = ?',
|
|
655
|
+
(name,)).fetchone()
|
|
656
|
+
if row is None:
|
|
657
|
+
raise ApiError(f"API key '{name}' not found", 404, code='key_not_found')
|
|
658
|
+
taken = conn.execute('SELECT name FROM api_keys WHERE (hash = ? OR previous_hash = ?) AND name <> ?',
|
|
659
|
+
(secret_hash, secret_hash, name)).fetchone()
|
|
660
|
+
if taken is not None:
|
|
661
|
+
raise ApiError("Another key already has this secret_sha256 - each key needs its own secret",
|
|
662
|
+
409, code='key_secret_taken')
|
|
663
|
+
if row['hash'] == secret_hash:
|
|
664
|
+
return None
|
|
665
|
+
if grace and row['previous_hash'] and row['previous_expires_at'] and row['previous_expires_at'] > store.now():
|
|
666
|
+
raise ApiError(f"The secret this key had before its last change works until {row['previous_expires_at']} "
|
|
667
|
+
'- end that first (DELETE .../previous), or wait for it', 409,
|
|
668
|
+
code='key_rotation_pending', previous_expires_at=row['previous_expires_at'])
|
|
669
|
+
return _replace_hash(conn, name, row, secret_hash, grace)
|
|
670
|
+
|
|
671
|
+
|
|
622
672
|
def end_previous(name):
|
|
623
673
|
"""Stop the old secret now, before its grace period ends. Returns when it would have stopped."""
|
|
624
674
|
with db.transaction() as conn:
|
|
@@ -685,19 +735,9 @@ def create_role(name, connections=None, allow_writes=False, queries=None, rate_l
|
|
|
685
735
|
if conn.execute('SELECT 1 FROM roles WHERE name = ?', (name,)).fetchone() is not None:
|
|
686
736
|
raise ApiError(f"A role named '{name}' already exists", 409, code='role_exists')
|
|
687
737
|
entry = {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
'collections': collections,
|
|
692
|
-
'rate_limit': _validate_rate_limit(rate_limit),
|
|
693
|
-
'allowed_ips': _validate_allowed_ips(allowed_ips),
|
|
694
|
-
'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
|
|
695
|
-
'allowed_tables': _validate_allowed_tables(allowed_tables),
|
|
696
|
-
'mask': masking.validate_rules(mask),
|
|
697
|
-
'max_concurrent': _validate_limit('max_concurrent', max_concurrent),
|
|
698
|
-
'query_timeout': _validate_limit('query_timeout', query_timeout),
|
|
699
|
-
'max_rows': _validate_limit('max_rows', max_rows),
|
|
700
|
-
'quota': quotas.validate(quota),
|
|
738
|
+
**normalize_grants(connections, allow_writes, queries, collections, rate_limit, allowed_ips,
|
|
739
|
+
allowed_write_ops, allowed_tables, mask, max_concurrent, query_timeout, max_rows,
|
|
740
|
+
quota),
|
|
701
741
|
'created_at': store.now(),
|
|
702
742
|
**({'example': True} if example else {}), # installed by `examples load` - see examples.py
|
|
703
743
|
}
|
|
@@ -91,6 +91,32 @@ class JSONProvider(DefaultJSONProvider):
|
|
|
91
91
|
sort_keys = False # keep result columns in the order the query returned them
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
def apply_config_at_startup():
|
|
95
|
+
"""QUERYAPIGATE_CONFIG_FILE (ADR 0014): apply it before serving - once per file, so with several instances the first
|
|
96
|
+
to start applies it and the others find it done. A file that can't be applied stops startup, changing nothing:
|
|
97
|
+
in a rolling update the new pods don't come up, and the ones still serving keep the configuration they have."""
|
|
98
|
+
from . import configcode
|
|
99
|
+
path = config.config_file()
|
|
100
|
+
if path is None:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
doc = configcode.load(path.read_text())
|
|
104
|
+
result = configcode.apply(doc, prune=config.config_prune(), actor='startup', once=True)
|
|
105
|
+
except OSError as error:
|
|
106
|
+
log.error('QUERYAPIGATE_CONFIG_FILE %s could not be read: %s', path, error)
|
|
107
|
+
raise ValueError(f'QUERYAPIGATE_CONFIG_FILE {path} could not be read: {error}') from None
|
|
108
|
+
except configcode.ConfigInvalid as error:
|
|
109
|
+
message = f'QUERYAPIGATE_CONFIG_FILE {path} was not applied - nothing in it was. ' + '; '.join(error.problems)
|
|
110
|
+
log.error('%s', message) # one line in the log, whatever prints the exception after (gunicorn: a traceback)
|
|
111
|
+
raise ValueError(message) from None
|
|
112
|
+
if result.get('already_applied'):
|
|
113
|
+
log.info('QUERYAPIGATE_CONFIG_FILE %s (sha256 %s) is already applied', path, result['file_sha256'][:12])
|
|
114
|
+
else:
|
|
115
|
+
counts = {a: sum(1 for c in result['changes'] if c['action'] == a) for a in ('create', 'update', 'delete')}
|
|
116
|
+
log.info('Applied QUERYAPIGATE_CONFIG_FILE %s (sha256 %s): %d created, %d changed, %d deleted', path,
|
|
117
|
+
result['file_sha256'][:12], counts['create'], counts['update'], counts['delete'])
|
|
118
|
+
|
|
119
|
+
|
|
94
120
|
def load_examples_at_startup():
|
|
95
121
|
"""QUERYAPIGATE_LOAD_EXAMPLES=yes: make sure the example APIs are installed. A problem (something of yours
|
|
96
122
|
holds an example's name, the home is read-only) is a warning, not a startup failure - the examples are a
|
|
@@ -190,6 +216,7 @@ def create_app():
|
|
|
190
216
|
_note_audit_key_rotation()
|
|
191
217
|
from . import auditstream
|
|
192
218
|
auditstream.start() # the audit log to a SIEM, when an output is set (ADR 0012)
|
|
219
|
+
apply_config_at_startup()
|
|
193
220
|
if config.load_examples():
|
|
194
221
|
load_examples_at_startup()
|
|
195
222
|
app.json = JSONProvider(app)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""AWS (ADR 0015): what `${aws-sm:...}` references and `aws-rds-iam` credentials resolve through - Secrets Manager, and
|
|
2
|
+
IAM authentication tokens for RDS and Aurora.
|
|
3
|
+
|
|
4
|
+
Through boto3 (the `aws` extra), with the standard credential chain - IRSA on EKS, an instance or task role, the usual
|
|
5
|
+
AWS_* environment variables - and region (QUERYAPIGATE_AWS_REGION, else AWS_REGION and the rest of the chain).
|
|
6
|
+
boto3's own AWS_ENDPOINT_URL_SECRETS_MANAGER points it elsewhere, for a VPC endpoint or a local stand-in.
|
|
7
|
+
|
|
8
|
+
Nothing here decides what may be read - secretrefs.py checks QUERYAPIGATE_AWS_SECRET_PREFIXES and
|
|
9
|
+
QUERYAPIGATE_AWS_RDS_HOSTS first.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
|
|
16
|
+
RDS_TOKEN_SECONDS = 900 # an IAM authentication token is good for 15 minutes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AwsError(Exception):
|
|
20
|
+
"""What AWS, or reaching it, refused - the message names why, never a secret."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_clients: dict[str, object] = {}
|
|
24
|
+
_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _client(service):
|
|
28
|
+
with _lock:
|
|
29
|
+
if service not in _clients:
|
|
30
|
+
try:
|
|
31
|
+
import boto3
|
|
32
|
+
except ImportError:
|
|
33
|
+
raise AwsError('the "boto3" package is not installed - pip install "queryapigate[aws]"') from None
|
|
34
|
+
_clients[service] = boto3.client(service, region_name=config.aws_region())
|
|
35
|
+
return _clients[service]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def reset():
|
|
39
|
+
with _lock:
|
|
40
|
+
_clients.clear()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _failure(error):
|
|
44
|
+
"""An AWS error as a sentence: its code and message, never what was asked for in full."""
|
|
45
|
+
response = getattr(error, 'response', None) or {}
|
|
46
|
+
code = (response.get('Error') or {}).get('Code')
|
|
47
|
+
message = (response.get('Error') or {}).get('Message') or str(error)
|
|
48
|
+
return f'{code}: {message}' if code else message
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def secret(secret_id, field=None):
|
|
52
|
+
"""A Secrets Manager secret's SecretString - or, with `field`, that field of its JSON: (value, None)."""
|
|
53
|
+
try:
|
|
54
|
+
answer = _client('secretsmanager').get_secret_value(SecretId=secret_id)
|
|
55
|
+
except AwsError:
|
|
56
|
+
raise
|
|
57
|
+
except Exception as error: # botocore's ClientError, or one of its connection errors
|
|
58
|
+
raise AwsError(_failure(error)) from None
|
|
59
|
+
text = answer.get('SecretString')
|
|
60
|
+
if text is None:
|
|
61
|
+
raise AwsError(f'{secret_id} holds binary data, not text')
|
|
62
|
+
if field is None:
|
|
63
|
+
return text, None
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(text)
|
|
66
|
+
except ValueError:
|
|
67
|
+
raise AwsError(f"{secret_id} isn't JSON, so it has no field '{field}'") from None
|
|
68
|
+
if not isinstance(data, dict) or field not in data:
|
|
69
|
+
raise AwsError(f"{secret_id} has no field '{field}'")
|
|
70
|
+
if not isinstance(data[field], str):
|
|
71
|
+
raise AwsError(f"{secret_id}#{field} isn't text")
|
|
72
|
+
return data[field], None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rds_token(host, port, user):
|
|
76
|
+
"""An IAM authentication token for `user` on the database at host:port - signed here, from the server's own AWS
|
|
77
|
+
credentials, with no call to AWS: ((user, token), seconds it lasts)."""
|
|
78
|
+
try:
|
|
79
|
+
region = {'Region': config.aws_region()} if config.aws_region() else {} # else the client's own region
|
|
80
|
+
token = _client('rds').generate_db_auth_token(DBHostname=host, Port=int(port), DBUsername=user, **region)
|
|
81
|
+
except AwsError:
|
|
82
|
+
raise
|
|
83
|
+
except Exception as error: # no credentials, no region
|
|
84
|
+
raise AwsError(_failure(error)) from None
|
|
85
|
+
return (user, token), RDS_TOKEN_SECONDS
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
``collection export|import``."""
|
|
3
3
|
import argparse
|
|
4
4
|
import contextlib
|
|
5
|
+
import hashlib
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import signal
|
|
8
11
|
import sys
|
|
9
12
|
import time
|
|
10
13
|
from datetime import datetime, timedelta
|
|
@@ -15,6 +18,7 @@ from . import (
|
|
|
15
18
|
admins,
|
|
16
19
|
bundle,
|
|
17
20
|
config,
|
|
21
|
+
configcode,
|
|
18
22
|
db,
|
|
19
23
|
examples,
|
|
20
24
|
experimental,
|
|
@@ -41,6 +45,9 @@ def _serve(args):
|
|
|
41
45
|
'Listening on %s without QUERYAPIGATE_API_KEY set: anyone who can reach this port can run SQL '
|
|
42
46
|
'on your active connections.', args.host)
|
|
43
47
|
logging.getLogger('queryapigate').info('Using %s (metadata: %s)', config.home(), db.describe())
|
|
48
|
+
# SIGTERM - what a container runtime or `kill` sends - as a clean exit, so what runs at exit does: Vault leases
|
|
49
|
+
# revoked (ADR 0015), the audit stream's lease given back. Python's default ends the process without it.
|
|
50
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
44
51
|
app.run(host=args.host, port=args.port, debug=args.debug, request_handler=_handler())
|
|
45
52
|
return 0
|
|
46
53
|
|
|
@@ -333,6 +340,130 @@ def _collection_import(args):
|
|
|
333
340
|
return 0
|
|
334
341
|
|
|
335
342
|
|
|
343
|
+
def _config_command(action):
|
|
344
|
+
"""Shared shell for the config subcommands (ADR 0014). Every failure exits 1, settings included: `config diff`
|
|
345
|
+
exits 2 for "there are changes", so a script can tell the two apart."""
|
|
346
|
+
def run(args):
|
|
347
|
+
try:
|
|
348
|
+
config.check_settings()
|
|
349
|
+
return action(args)
|
|
350
|
+
except configcode.ConfigInvalid as error:
|
|
351
|
+
print(f'queryapigate config {args.action}: {len(error.problems)} problem(s) in the file:', file=sys.stderr)
|
|
352
|
+
for problem in error.problems:
|
|
353
|
+
print(f' {problem}', file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
except ApiError as error:
|
|
356
|
+
print(f'queryapigate config {args.action}: {error.message}', file=sys.stderr)
|
|
357
|
+
return 1
|
|
358
|
+
except (OSError, ValueError) as error:
|
|
359
|
+
print(f'queryapigate config {args.action}: {error}', file=sys.stderr)
|
|
360
|
+
return 1
|
|
361
|
+
return run
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _read_config_file(path):
|
|
365
|
+
if path == '-':
|
|
366
|
+
return configcode.load(sys.stdin.read())
|
|
367
|
+
with open(path) as f:
|
|
368
|
+
return configcode.load(f.read())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@_config_command
|
|
372
|
+
def _config_export(args):
|
|
373
|
+
kinds = tuple(k.strip() for k in args.kinds.split(',')) if args.kinds else configcode.KINDS
|
|
374
|
+
doc, variables, skipped = configcode.export(kinds, examples=args.examples)
|
|
375
|
+
text = configcode.dump(doc, variables)
|
|
376
|
+
if args.out is None:
|
|
377
|
+
sys.stdout.write(text)
|
|
378
|
+
else:
|
|
379
|
+
out_dir = os.path.dirname(args.out) or '.'
|
|
380
|
+
tmp_path = os.path.join(out_dir, f'.{os.path.basename(args.out)}.part')
|
|
381
|
+
try:
|
|
382
|
+
with open(tmp_path, 'w') as f:
|
|
383
|
+
f.write(text)
|
|
384
|
+
os.replace(tmp_path, args.out) # a failed run never leaves a partial file at the destination
|
|
385
|
+
finally:
|
|
386
|
+
if os.path.exists(tmp_path):
|
|
387
|
+
os.remove(tmp_path)
|
|
388
|
+
print(f'Wrote the configuration to {args.out}', file=sys.stderr)
|
|
389
|
+
for variable in variables:
|
|
390
|
+
print(f"Set {variable['name']} where the server runs: {variable['for']}'s password, stored "
|
|
391
|
+
f"{variable['stored']} here", file=sys.stderr)
|
|
392
|
+
for name in skipped:
|
|
393
|
+
print(f'Left out saved query {name}: it has no published version', file=sys.stderr)
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _remote(args, operation, **flags):
|
|
398
|
+
"""POST the file to a running server's /api/v1/config/<operation> (`--server`), with the key in QAG_API_KEY - an
|
|
399
|
+
environment variable, so it never shows in a CI log or a process list."""
|
|
400
|
+
import urllib.error
|
|
401
|
+
import urllib.request
|
|
402
|
+
key = os.environ.get('QAG_API_KEY', '').strip()
|
|
403
|
+
if not key:
|
|
404
|
+
raise ApiError('--server needs the API key in the QAG_API_KEY environment variable')
|
|
405
|
+
doc = _read_config_file(args.file)
|
|
406
|
+
query = '&'.join(f'{k}=true' for k, v in flags.items() if v)
|
|
407
|
+
url = args.server.rstrip('/') + f'/api/v1/config/{operation}' + (f'?{query}' if query else '')
|
|
408
|
+
request = urllib.request.Request(url, data=json.dumps(doc).encode(), method='POST',
|
|
409
|
+
headers={'X-API-Key': key, 'Content-Type': 'application/json'})
|
|
410
|
+
try:
|
|
411
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
412
|
+
return json.load(response)
|
|
413
|
+
except urllib.error.HTTPError as error:
|
|
414
|
+
try:
|
|
415
|
+
body = json.load(error)
|
|
416
|
+
except ValueError:
|
|
417
|
+
raise ApiError(f'{url} answered {error.code}') from None
|
|
418
|
+
if body.get('code') == 'invalid_config':
|
|
419
|
+
raise configcode.ConfigInvalid(body.get('problems') or [body.get('error')]) from None
|
|
420
|
+
raise ApiError(f"{url} answered {error.code}: {body.get('error')}") from None
|
|
421
|
+
except urllib.error.URLError as error:
|
|
422
|
+
raise ApiError(f'{url}: {error.reason}') from None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@_config_command
|
|
426
|
+
def _config_diff(args):
|
|
427
|
+
if args.server:
|
|
428
|
+
result = _remote(args, 'diff', prune=args.prune)
|
|
429
|
+
else:
|
|
430
|
+
result = configcode.diff(_read_config_file(args.file), prune=args.prune)
|
|
431
|
+
if args.json:
|
|
432
|
+
print(json.dumps(result, indent=2, default=str))
|
|
433
|
+
else:
|
|
434
|
+
print(configcode.render(result))
|
|
435
|
+
return 2 if result['changes'] else 0
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@_config_command
|
|
439
|
+
def _config_apply(args):
|
|
440
|
+
if args.server:
|
|
441
|
+
result = _remote(args, 'apply', prune=args.prune, dry_run=args.dry_run)
|
|
442
|
+
elif args.dry_run:
|
|
443
|
+
result = {**configcode.diff(_read_config_file(args.file), prune=args.prune), 'applied': False}
|
|
444
|
+
else:
|
|
445
|
+
result = configcode.apply(_read_config_file(args.file), prune=args.prune, actor='cli')
|
|
446
|
+
if args.json:
|
|
447
|
+
print(json.dumps(result, indent=2, default=str))
|
|
448
|
+
return 0
|
|
449
|
+
print(configcode.render(result))
|
|
450
|
+
if args.dry_run:
|
|
451
|
+
print('Dry run: nothing was changed.')
|
|
452
|
+
elif result['applied']:
|
|
453
|
+
print(f"Applied: the server matches the file (sha256 {result['file_sha256'][:12]}).")
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _config_new_key(args):
|
|
458
|
+
"""A fresh API key secret and its SHA-256, for a key declared in a configuration file. Nothing is stored."""
|
|
459
|
+
secret = 'sk_' + secrets.token_urlsafe(32)
|
|
460
|
+
print(f'secret: {secret}')
|
|
461
|
+
print(f'secret_sha256: {hashlib.sha256(secret.encode()).hexdigest()}')
|
|
462
|
+
print('Give the secret to whoever uses the key, keep it in your secrets manager, and put only secret_sha256 in '
|
|
463
|
+
'the file.', file=sys.stderr)
|
|
464
|
+
return 0
|
|
465
|
+
|
|
466
|
+
|
|
336
467
|
def _examples_command(action):
|
|
337
468
|
"""Shared shell for the examples subcommands - the same settings check and clean-error handling as the
|
|
338
469
|
collection ones."""
|
|
@@ -686,6 +817,42 @@ def build_parser():
|
|
|
686
817
|
collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
|
|
687
818
|
collection_import.set_defaults(func=_collection_import)
|
|
688
819
|
|
|
820
|
+
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014, experimental): "
|
|
821
|
+
'export it, see what applying one would change, apply it')
|
|
822
|
+
config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
|
|
823
|
+
config_actions = config_parser.add_subparsers(dest='action')
|
|
824
|
+
config_export = config_actions.add_parser(
|
|
825
|
+
'export', help='write connections, destinations, roles, saved queries, API keys, descriptions and exports as '
|
|
826
|
+
'one YAML file - passwords as ${VAR} references, keys as their hashes, never a secret')
|
|
827
|
+
config_export.add_argument('--kinds', help=f"only these, comma-separated (of: {', '.join(configcode.KINDS)})")
|
|
828
|
+
config_export.add_argument('--examples', action='store_true', help='include the bundled examples too')
|
|
829
|
+
config_export.add_argument('--out', help='write to this file instead of stdout')
|
|
830
|
+
config_export.set_defaults(func=_config_export)
|
|
831
|
+
config_diff = config_actions.add_parser(
|
|
832
|
+
'diff', help='what applying FILE would create and change, field by field - writes nothing. Exit 0: no '
|
|
833
|
+
'changes; 2: changes; 1: the file has problems')
|
|
834
|
+
config_diff.add_argument('file', help='a configuration file (YAML or JSON), or - for stdin')
|
|
835
|
+
config_diff.add_argument('--json', action='store_true', help='the changes as JSON, for a script')
|
|
836
|
+
config_diff.add_argument('--prune', action='store_true', help='also show what --prune would delete')
|
|
837
|
+
config_diff.add_argument('--server', metavar='URL', help="a running server's base URL, instead of this home's "
|
|
838
|
+
'store - the key in QAG_API_KEY (CI against prod)')
|
|
839
|
+
config_diff.set_defaults(func=_config_diff)
|
|
840
|
+
config_apply = config_actions.add_parser(
|
|
841
|
+
'apply', help='make the server match FILE, all or nothing - every change through the same rules as the API, '
|
|
842
|
+
'audited; what the file does not name is left alone')
|
|
843
|
+
config_apply.add_argument('file', help='a configuration file (YAML or JSON), or - for stdin')
|
|
844
|
+
config_apply.add_argument('--prune', action='store_true',
|
|
845
|
+
help='also delete what a file manages (created, changed or named before) and this one '
|
|
846
|
+
'no longer has - never what was made in the Console or the API')
|
|
847
|
+
config_apply.add_argument('--dry-run', action='store_true', help='say what would change; change nothing')
|
|
848
|
+
config_apply.add_argument('--json', action='store_true', help='the result as JSON, for a script')
|
|
849
|
+
config_apply.add_argument('--server', metavar='URL', help="a running server's base URL, instead of this home's "
|
|
850
|
+
'store - the key in QAG_API_KEY (an owner\'s)')
|
|
851
|
+
config_apply.set_defaults(func=_config_apply)
|
|
852
|
+
config_new_key = config_actions.add_parser(
|
|
853
|
+
'new-key', help="a fresh API key secret and its secret_sha256, for a key declared in a file - nothing stored")
|
|
854
|
+
config_new_key.set_defaults(func=_config_new_key)
|
|
855
|
+
|
|
689
856
|
example_parser = commands.add_parser('examples', help='load, remove or check the bundled example APIs '
|
|
690
857
|
'(reporting, dashboard, export, partner)')
|
|
691
858
|
example_parser.set_defaults(func=lambda a: example_parser.print_help() or 2)
|
|
@@ -759,6 +926,8 @@ def main(argv=None):
|
|
|
759
926
|
args = parser.parse_args(['serve', *(argv or [])])
|
|
760
927
|
if getattr(args, 'home', None):
|
|
761
928
|
os.environ['QUERYAPIGATE_HOME'] = args.home
|
|
929
|
+
if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
|
|
930
|
+
return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
|
|
762
931
|
try:
|
|
763
932
|
config.check_database_url()
|
|
764
933
|
db.init_schema()
|