queryapigate 0.26.0__tar.gz → 0.27.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.27.0}/PKG-INFO +1 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/adminroles.py +3 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/apikeys.py +72 -32
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/app.py +27 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/cli.py +165 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/config.py +33 -0
- queryapigate-0.27.0/queryapigate/configcode.py +1035 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/AccessMapPage-rRSiv2mm.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AccessMapPage-B3WpxIky.js +3 -3
- queryapigate-0.26.0/queryapigate/console_dist/assets/AdministratorsPage-DoeYjW1i.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AdministratorsPage-B64xDs7h.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/AgentsPage-DerF6ZBb.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AgentsPage-CLvbAeMM.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/AlertsPage-BWROcwNn.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AlertsPage-DMT7ahQ0.js +1 -1
- queryapigate-0.27.0/queryapigate/console_dist/assets/ApiKeysPage-tDe3VPtQ.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/ApprovalsPage-DSKisl9D.js → queryapigate-0.27.0/queryapigate/console_dist/assets/ApprovalsPage-AQ1RBm3Y.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/AuditLogPage-Etti--Y7.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AuditLogPage-DSIK5iAl.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/CachingPage-BZ61rPfT.js → queryapigate-0.27.0/queryapigate/console_dist/assets/CachingPage-BTYUmEQF.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/CodeBox-GGv4fKoR.js → queryapigate-0.27.0/queryapigate/console_dist/assets/CodeBox-CLn9Isny.js +1 -1
- queryapigate-0.27.0/queryapigate/console_dist/assets/ConnectionsPage-CU3lwLIy.js +4 -0
- queryapigate-0.27.0/queryapigate/console_dist/assets/DesignerPage-BDbMWCV_.js +4 -0
- queryapigate-0.27.0/queryapigate/console_dist/assets/DestinationsPage-DWvgv64P.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/Experimental-BB0g6F3s.js → queryapigate-0.27.0/queryapigate/console_dist/assets/Experimental-Ci4sxNHA.js +1 -1
- queryapigate-0.27.0/queryapigate/console_dist/assets/ExportsPage-Bd6RtYfq.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/HelpPage-CUTTBoH6.js → queryapigate-0.27.0/queryapigate/console_dist/assets/HelpPage-Vs990qgv.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/HomePage-DtOzlurj.js → queryapigate-0.27.0/queryapigate/console_dist/assets/HomePage-D9fA6CUM.js +3 -3
- queryapigate-0.27.0/queryapigate/console_dist/assets/Managed-D1nsYxSb.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/MetricsPage-o3kSXmOq.js → queryapigate-0.27.0/queryapigate/console_dist/assets/MetricsPage-FbI8yJ_V.js +1 -1
- queryapigate-0.27.0/queryapigate/console_dist/assets/RepositoryPage-BCeeBwLm.js +4 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/Results-xBStmWdp.js → queryapigate-0.27.0/queryapigate/console_dist/assets/Results-TOzveYeD.js +2 -2
- queryapigate-0.27.0/queryapigate/console_dist/assets/RolesPage-C-enuQa1.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/SchemaBrowser-DykbAObL.js → queryapigate-0.27.0/queryapigate/console_dist/assets/SchemaBrowser-BMa0-8U2.js +4 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/SettingsPage-mw196Ecu.js → queryapigate-0.27.0/queryapigate/console_dist/assets/SettingsPage-Du4eemQO.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/StatTile-5XA5PKWp.js → queryapigate-0.27.0/queryapigate/console_dist/assets/StatTile-CxCBu4uV.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/Time-zMnS1kSm.js → queryapigate-0.27.0/queryapigate/console_dist/assets/Time-rOk1OPXf.js +1 -1
- queryapigate-0.27.0/queryapigate/console_dist/assets/forms-CWRg8NCI.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/forms-Cl7JC3vt.js → queryapigate-0.27.0/queryapigate/console_dist/assets/forms-DWiJkRV3.js +2 -2
- queryapigate-0.26.0/queryapigate/console_dist/assets/icons-B_wbfO4Z.js → queryapigate-0.27.0/queryapigate/console_dist/assets/icons-CaGQH4Og.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/index-D9oV4vVP.css → queryapigate-0.27.0/queryapigate/console_dist/assets/index-BLd0t6vZ.css +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/index-D0zbyWWM.js → queryapigate-0.27.0/queryapigate/console_dist/assets/index-KpcrIrQ6.js +4 -4
- queryapigate-0.26.0/queryapigate/console_dist/assets/maskRules-Cw-l_UG8.js → queryapigate-0.27.0/queryapigate/console_dist/assets/maskRules-DYn9Uy4e.js +1 -1
- queryapigate-0.26.0/queryapigate/console_dist/assets/reach-DFqBXYli.js → queryapigate-0.27.0/queryapigate/console_dist/assets/reach-BiJLHQVp.js +1 -1
- queryapigate-0.27.0/queryapigate/console_dist/assets/shared-DhbY3zlT.js +1 -0
- queryapigate-0.26.0/queryapigate/console_dist/assets/useMutation-BCy3Br67.js → queryapigate-0.27.0/queryapigate/console_dist/assets/useMutation-CKRawbcZ.js +1 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console_dist/index.html +2 -2
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/db.py +82 -4
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/experimental.py +8 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/exports.py +19 -8
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/history.py +2 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/store.py +40 -15
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/v1.py +58 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/v1_spec.py +78 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0/queryapigate.egg-info}/PKG-INFO +1 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/SOURCES.txt +36 -33
- queryapigate-0.27.0/tests/test_configcode.py +924 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_helm.py +33 -1
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_settings.py +1 -1
- 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.27.0}/LICENSE +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/README.md +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/pyproject.toml +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/admins.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/alerts.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/auditstream.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/bundle.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/databases.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/definitions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/destinations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/engine.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/events.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/exporting.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/formats.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/governance.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/instances.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/limits.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/masking.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/mcp_server.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/metrics.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/oidc.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/openapi.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/params.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/parquet.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/pool.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/quotas.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/ratelimit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/rekey.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/runners.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/schema.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/access.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/sqltools.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/sso.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/tableguard.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/tracing.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/usage.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/requires.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/setup.cfg +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_adminroles.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_admins.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_alerts.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_api_v1.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_audit_log.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_broadcast.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_cli.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_collections.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_console.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_databases.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_db.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_dependency_floors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_deprecations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_descriptions.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_destinations.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_errors.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_events.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_events_server.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_examples.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_experimental.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_export_security.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_exporting.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_exports.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_history.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_howto.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_instances.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_integration.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_jwt.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_limits.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_masking.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mcp.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mongotools.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_multinode.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_observability.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_oidc.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_params.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_parquet.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_pool.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_publishing.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_query_window.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_queryapigate.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_quotas.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_rekey.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_roles.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_rolling_upgrade.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_security_headers.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_shared_rate_limit.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sql_guard_fuzz.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sql_guard_writes.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sso.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_tableguard.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_tracing.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_upgrades.py +0 -0
- {queryapigate-0.26.0 → queryapigate-0.27.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.27.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
|
|
@@ -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
|
|
|
@@ -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)
|
|
@@ -2,9 +2,11 @@
|
|
|
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
|
|
8
10
|
import sys
|
|
9
11
|
import time
|
|
10
12
|
from datetime import datetime, timedelta
|
|
@@ -15,6 +17,7 @@ from . import (
|
|
|
15
17
|
admins,
|
|
16
18
|
bundle,
|
|
17
19
|
config,
|
|
20
|
+
configcode,
|
|
18
21
|
db,
|
|
19
22
|
examples,
|
|
20
23
|
experimental,
|
|
@@ -333,6 +336,130 @@ def _collection_import(args):
|
|
|
333
336
|
return 0
|
|
334
337
|
|
|
335
338
|
|
|
339
|
+
def _config_command(action):
|
|
340
|
+
"""Shared shell for the config subcommands (ADR 0014). Every failure exits 1, settings included: `config diff`
|
|
341
|
+
exits 2 for "there are changes", so a script can tell the two apart."""
|
|
342
|
+
def run(args):
|
|
343
|
+
try:
|
|
344
|
+
config.check_settings()
|
|
345
|
+
return action(args)
|
|
346
|
+
except configcode.ConfigInvalid as error:
|
|
347
|
+
print(f'queryapigate config {args.action}: {len(error.problems)} problem(s) in the file:', file=sys.stderr)
|
|
348
|
+
for problem in error.problems:
|
|
349
|
+
print(f' {problem}', file=sys.stderr)
|
|
350
|
+
return 1
|
|
351
|
+
except ApiError as error:
|
|
352
|
+
print(f'queryapigate config {args.action}: {error.message}', file=sys.stderr)
|
|
353
|
+
return 1
|
|
354
|
+
except (OSError, ValueError) as error:
|
|
355
|
+
print(f'queryapigate config {args.action}: {error}', file=sys.stderr)
|
|
356
|
+
return 1
|
|
357
|
+
return run
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _read_config_file(path):
|
|
361
|
+
if path == '-':
|
|
362
|
+
return configcode.load(sys.stdin.read())
|
|
363
|
+
with open(path) as f:
|
|
364
|
+
return configcode.load(f.read())
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@_config_command
|
|
368
|
+
def _config_export(args):
|
|
369
|
+
kinds = tuple(k.strip() for k in args.kinds.split(',')) if args.kinds else configcode.KINDS
|
|
370
|
+
doc, variables, skipped = configcode.export(kinds, examples=args.examples)
|
|
371
|
+
text = configcode.dump(doc, variables)
|
|
372
|
+
if args.out is None:
|
|
373
|
+
sys.stdout.write(text)
|
|
374
|
+
else:
|
|
375
|
+
out_dir = os.path.dirname(args.out) or '.'
|
|
376
|
+
tmp_path = os.path.join(out_dir, f'.{os.path.basename(args.out)}.part')
|
|
377
|
+
try:
|
|
378
|
+
with open(tmp_path, 'w') as f:
|
|
379
|
+
f.write(text)
|
|
380
|
+
os.replace(tmp_path, args.out) # a failed run never leaves a partial file at the destination
|
|
381
|
+
finally:
|
|
382
|
+
if os.path.exists(tmp_path):
|
|
383
|
+
os.remove(tmp_path)
|
|
384
|
+
print(f'Wrote the configuration to {args.out}', file=sys.stderr)
|
|
385
|
+
for variable in variables:
|
|
386
|
+
print(f"Set {variable['name']} where the server runs: {variable['for']}'s password, stored "
|
|
387
|
+
f"{variable['stored']} here", file=sys.stderr)
|
|
388
|
+
for name in skipped:
|
|
389
|
+
print(f'Left out saved query {name}: it has no published version', file=sys.stderr)
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _remote(args, operation, **flags):
|
|
394
|
+
"""POST the file to a running server's /api/v1/config/<operation> (`--server`), with the key in QAG_API_KEY - an
|
|
395
|
+
environment variable, so it never shows in a CI log or a process list."""
|
|
396
|
+
import urllib.error
|
|
397
|
+
import urllib.request
|
|
398
|
+
key = os.environ.get('QAG_API_KEY', '').strip()
|
|
399
|
+
if not key:
|
|
400
|
+
raise ApiError('--server needs the API key in the QAG_API_KEY environment variable')
|
|
401
|
+
doc = _read_config_file(args.file)
|
|
402
|
+
query = '&'.join(f'{k}=true' for k, v in flags.items() if v)
|
|
403
|
+
url = args.server.rstrip('/') + f'/api/v1/config/{operation}' + (f'?{query}' if query else '')
|
|
404
|
+
request = urllib.request.Request(url, data=json.dumps(doc).encode(), method='POST',
|
|
405
|
+
headers={'X-API-Key': key, 'Content-Type': 'application/json'})
|
|
406
|
+
try:
|
|
407
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
408
|
+
return json.load(response)
|
|
409
|
+
except urllib.error.HTTPError as error:
|
|
410
|
+
try:
|
|
411
|
+
body = json.load(error)
|
|
412
|
+
except ValueError:
|
|
413
|
+
raise ApiError(f'{url} answered {error.code}') from None
|
|
414
|
+
if body.get('code') == 'invalid_config':
|
|
415
|
+
raise configcode.ConfigInvalid(body.get('problems') or [body.get('error')]) from None
|
|
416
|
+
raise ApiError(f"{url} answered {error.code}: {body.get('error')}") from None
|
|
417
|
+
except urllib.error.URLError as error:
|
|
418
|
+
raise ApiError(f'{url}: {error.reason}') from None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@_config_command
|
|
422
|
+
def _config_diff(args):
|
|
423
|
+
if args.server:
|
|
424
|
+
result = _remote(args, 'diff', prune=args.prune)
|
|
425
|
+
else:
|
|
426
|
+
result = configcode.diff(_read_config_file(args.file), prune=args.prune)
|
|
427
|
+
if args.json:
|
|
428
|
+
print(json.dumps(result, indent=2, default=str))
|
|
429
|
+
else:
|
|
430
|
+
print(configcode.render(result))
|
|
431
|
+
return 2 if result['changes'] else 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@_config_command
|
|
435
|
+
def _config_apply(args):
|
|
436
|
+
if args.server:
|
|
437
|
+
result = _remote(args, 'apply', prune=args.prune, dry_run=args.dry_run)
|
|
438
|
+
elif args.dry_run:
|
|
439
|
+
result = {**configcode.diff(_read_config_file(args.file), prune=args.prune), 'applied': False}
|
|
440
|
+
else:
|
|
441
|
+
result = configcode.apply(_read_config_file(args.file), prune=args.prune, actor='cli')
|
|
442
|
+
if args.json:
|
|
443
|
+
print(json.dumps(result, indent=2, default=str))
|
|
444
|
+
return 0
|
|
445
|
+
print(configcode.render(result))
|
|
446
|
+
if args.dry_run:
|
|
447
|
+
print('Dry run: nothing was changed.')
|
|
448
|
+
elif result['applied']:
|
|
449
|
+
print(f"Applied: the server matches the file (sha256 {result['file_sha256'][:12]}).")
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _config_new_key(args):
|
|
454
|
+
"""A fresh API key secret and its SHA-256, for a key declared in a configuration file. Nothing is stored."""
|
|
455
|
+
secret = 'sk_' + secrets.token_urlsafe(32)
|
|
456
|
+
print(f'secret: {secret}')
|
|
457
|
+
print(f'secret_sha256: {hashlib.sha256(secret.encode()).hexdigest()}')
|
|
458
|
+
print('Give the secret to whoever uses the key, keep it in your secrets manager, and put only secret_sha256 in '
|
|
459
|
+
'the file.', file=sys.stderr)
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
|
|
336
463
|
def _examples_command(action):
|
|
337
464
|
"""Shared shell for the examples subcommands - the same settings check and clean-error handling as the
|
|
338
465
|
collection ones."""
|
|
@@ -686,6 +813,42 @@ def build_parser():
|
|
|
686
813
|
collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
|
|
687
814
|
collection_import.set_defaults(func=_collection_import)
|
|
688
815
|
|
|
816
|
+
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014, experimental): "
|
|
817
|
+
'export it, see what applying one would change, apply it')
|
|
818
|
+
config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
|
|
819
|
+
config_actions = config_parser.add_subparsers(dest='action')
|
|
820
|
+
config_export = config_actions.add_parser(
|
|
821
|
+
'export', help='write connections, destinations, roles, saved queries, API keys, descriptions and exports as '
|
|
822
|
+
'one YAML file - passwords as ${VAR} references, keys as their hashes, never a secret')
|
|
823
|
+
config_export.add_argument('--kinds', help=f"only these, comma-separated (of: {', '.join(configcode.KINDS)})")
|
|
824
|
+
config_export.add_argument('--examples', action='store_true', help='include the bundled examples too')
|
|
825
|
+
config_export.add_argument('--out', help='write to this file instead of stdout')
|
|
826
|
+
config_export.set_defaults(func=_config_export)
|
|
827
|
+
config_diff = config_actions.add_parser(
|
|
828
|
+
'diff', help='what applying FILE would create and change, field by field - writes nothing. Exit 0: no '
|
|
829
|
+
'changes; 2: changes; 1: the file has problems')
|
|
830
|
+
config_diff.add_argument('file', help='a configuration file (YAML or JSON), or - for stdin')
|
|
831
|
+
config_diff.add_argument('--json', action='store_true', help='the changes as JSON, for a script')
|
|
832
|
+
config_diff.add_argument('--prune', action='store_true', help='also show what --prune would delete')
|
|
833
|
+
config_diff.add_argument('--server', metavar='URL', help="a running server's base URL, instead of this home's "
|
|
834
|
+
'store - the key in QAG_API_KEY (CI against prod)')
|
|
835
|
+
config_diff.set_defaults(func=_config_diff)
|
|
836
|
+
config_apply = config_actions.add_parser(
|
|
837
|
+
'apply', help='make the server match FILE, all or nothing - every change through the same rules as the API, '
|
|
838
|
+
'audited; what the file does not name is left alone')
|
|
839
|
+
config_apply.add_argument('file', help='a configuration file (YAML or JSON), or - for stdin')
|
|
840
|
+
config_apply.add_argument('--prune', action='store_true',
|
|
841
|
+
help='also delete what a file manages (created, changed or named before) and this one '
|
|
842
|
+
'no longer has - never what was made in the Console or the API')
|
|
843
|
+
config_apply.add_argument('--dry-run', action='store_true', help='say what would change; change nothing')
|
|
844
|
+
config_apply.add_argument('--json', action='store_true', help='the result as JSON, for a script')
|
|
845
|
+
config_apply.add_argument('--server', metavar='URL', help="a running server's base URL, instead of this home's "
|
|
846
|
+
'store - the key in QAG_API_KEY (an owner\'s)')
|
|
847
|
+
config_apply.set_defaults(func=_config_apply)
|
|
848
|
+
config_new_key = config_actions.add_parser(
|
|
849
|
+
'new-key', help="a fresh API key secret and its secret_sha256, for a key declared in a file - nothing stored")
|
|
850
|
+
config_new_key.set_defaults(func=_config_new_key)
|
|
851
|
+
|
|
689
852
|
example_parser = commands.add_parser('examples', help='load, remove or check the bundled example APIs '
|
|
690
853
|
'(reporting, dashboard, export, partner)')
|
|
691
854
|
example_parser.set_defaults(func=lambda a: example_parser.print_help() or 2)
|
|
@@ -759,6 +922,8 @@ def main(argv=None):
|
|
|
759
922
|
args = parser.parse_args(['serve', *(argv or [])])
|
|
760
923
|
if getattr(args, 'home', None):
|
|
761
924
|
os.environ['QUERYAPIGATE_HOME'] = args.home
|
|
925
|
+
if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
|
|
926
|
+
return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
|
|
762
927
|
try:
|
|
763
928
|
config.check_database_url()
|
|
764
929
|
db.init_schema()
|
|
@@ -64,6 +64,25 @@ def load_examples():
|
|
|
64
64
|
return env_flag('QUERYAPIGATE_LOAD_EXAMPLES')
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def config_file():
|
|
68
|
+
"""QUERYAPIGATE_CONFIG_FILE: a configuration file (ADR 0014) applied at startup, before the server serves - once per
|
|
69
|
+
file: an instance that finds it already applied leaves the store alone. None when unset."""
|
|
70
|
+
raw = os.environ.get('QUERYAPIGATE_CONFIG_FILE', '').strip()
|
|
71
|
+
return Path(raw) if raw else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def config_prune():
|
|
75
|
+
"""QUERYAPIGATE_CONFIG_PRUNE: applying QUERYAPIGATE_CONFIG_FILE at startup also deletes what a file manages and this
|
|
76
|
+
one no longer has (`config apply --prune`)."""
|
|
77
|
+
return env_flag('QUERYAPIGATE_CONFIG_PRUNE')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def config_locked():
|
|
81
|
+
"""QUERYAPIGATE_CONFIG_LOCKED: what a configuration file manages can be changed only through a file - the API and
|
|
82
|
+
the Console answer 409 config_managed (ADR 0014)."""
|
|
83
|
+
return env_flag('QUERYAPIGATE_CONFIG_LOCKED')
|
|
84
|
+
|
|
85
|
+
|
|
67
86
|
def home():
|
|
68
87
|
"""Folder holding queryapigate.db (QUERYAPIGATE_HOME, default: current directory)."""
|
|
69
88
|
return _resolved_home(os.environ.get('QUERYAPIGATE_HOME') or '', os.getcwd())
|
|
@@ -335,6 +354,13 @@ def check_settings():
|
|
|
335
354
|
raw = os.environ.get('QUERYAPIGATE_LOAD_EXAMPLES', '').strip().lower()
|
|
336
355
|
if raw not in _FLAG_WORDS:
|
|
337
356
|
raise ValueError(f"QUERYAPIGATE_LOAD_EXAMPLES must be yes or no (or 1/0, true/false, on/off), not '{raw}'")
|
|
357
|
+
for name in ('QUERYAPIGATE_CONFIG_PRUNE', 'QUERYAPIGATE_CONFIG_LOCKED'):
|
|
358
|
+
raw = os.environ.get(name, '').strip().lower()
|
|
359
|
+
if raw not in _FLAG_WORDS:
|
|
360
|
+
raise ValueError(f"{name} must be yes or no (or 1/0, true/false, on/off), not '{raw}'")
|
|
361
|
+
if config_prune() and config_file() is None:
|
|
362
|
+
raise ValueError('QUERYAPIGATE_CONFIG_PRUNE is set but QUERYAPIGATE_CONFIG_FILE is not - prune what, against '
|
|
363
|
+
'which file?')
|
|
338
364
|
raw = os.environ.get('QUERYAPIGATE_RATE_LIMIT', '').strip()
|
|
339
365
|
if raw:
|
|
340
366
|
parse_rate_limit(raw)
|
|
@@ -1004,6 +1030,13 @@ def describe_settings():
|
|
|
1004
1030
|
else f'PostgreSQL ({redact_url(database_url())})', secret=True),
|
|
1005
1031
|
row('Load examples', 'Load the example APIs at startup. Idempotent.',
|
|
1006
1032
|
'QUERYAPIGATE_LOAD_EXAMPLES', on_off(load_examples())),
|
|
1033
|
+
row('Configuration file', 'A configuration file applied at startup, once per file - connections, '
|
|
1034
|
+
'queries, roles, keys and the rest as code (ADR 0014).', 'QUERYAPIGATE_CONFIG_FILE',
|
|
1035
|
+
str(config_file()) if config_file() else 'none'),
|
|
1036
|
+
row('Prune at startup', 'Applying it also deletes what a file manages and this one no longer has.',
|
|
1037
|
+
'QUERYAPIGATE_CONFIG_PRUNE', on_off(config_prune())),
|
|
1038
|
+
row('Locked to the file', 'What a file manages can be changed only through a file, not here or the API.',
|
|
1039
|
+
'QUERYAPIGATE_CONFIG_LOCKED', on_off(config_locked())),
|
|
1007
1040
|
row('H2 driver', 'JAR used for h2 connections.', 'QUERYAPIGATE_H2_JAR',
|
|
1008
1041
|
Path(h2_jar()).name + ('' if os.environ.get('QUERYAPIGATE_H2_JAR') else ' (bundled)'))]},
|
|
1009
1042
|
{'id': 'security', 'title': 'Security',
|