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.
Files changed (220) hide show
  1. {queryapigate-0.26.0/queryapigate.egg-info → queryapigate-0.28.0}/PKG-INFO +4 -1
  2. {queryapigate-0.26.0 → queryapigate-0.28.0}/pyproject.toml +5 -1
  3. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/__init__.py +1 -1
  4. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/adminroles.py +3 -1
  5. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/alerts.py +22 -2
  6. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/apikeys.py +72 -32
  7. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/app.py +27 -0
  8. queryapigate-0.28.0/queryapigate/aws.py +85 -0
  9. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/cli.py +169 -0
  10. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/config.py +247 -0
  11. queryapigate-0.28.0/queryapigate/configcode.py +1034 -0
  12. queryapigate-0.26.0/queryapigate/console_dist/assets/AccessMapPage-rRSiv2mm.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AccessMapPage-DIu936ZH.js +3 -3
  13. 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
  14. queryapigate-0.26.0/queryapigate/console_dist/assets/AgentsPage-DerF6ZBb.js → queryapigate-0.28.0/queryapigate/console_dist/assets/AgentsPage-toV9AXCc.js +1 -1
  15. 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
  16. queryapigate-0.28.0/queryapigate/console_dist/assets/ApiKeysPage-C0VW5NrS.js +1 -0
  17. queryapigate-0.26.0/queryapigate/console_dist/assets/ApprovalsPage-DSKisl9D.js → queryapigate-0.28.0/queryapigate/console_dist/assets/ApprovalsPage-eyGXo0ky.js +1 -1
  18. 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
  19. queryapigate-0.26.0/queryapigate/console_dist/assets/CachingPage-BZ61rPfT.js → queryapigate-0.28.0/queryapigate/console_dist/assets/CachingPage-4stBdbWQ.js +1 -1
  20. queryapigate-0.26.0/queryapigate/console_dist/assets/CodeBox-GGv4fKoR.js → queryapigate-0.28.0/queryapigate/console_dist/assets/CodeBox-DRfv6yXf.js +1 -1
  21. queryapigate-0.28.0/queryapigate/console_dist/assets/ConnectionsPage-XYubobx_.js +4 -0
  22. queryapigate-0.28.0/queryapigate/console_dist/assets/DesignerPage-B1Sc83US.js +4 -0
  23. queryapigate-0.28.0/queryapigate/console_dist/assets/DestinationsPage-C30f018_.js +1 -0
  24. queryapigate-0.26.0/queryapigate/console_dist/assets/Experimental-BB0g6F3s.js → queryapigate-0.28.0/queryapigate/console_dist/assets/Experimental-BD3aGuwW.js +1 -1
  25. queryapigate-0.28.0/queryapigate/console_dist/assets/ExportsPage-BMRj3VOx.js +1 -0
  26. queryapigate-0.26.0/queryapigate/console_dist/assets/HelpPage-CUTTBoH6.js → queryapigate-0.28.0/queryapigate/console_dist/assets/HelpPage-DCkoTqa5.js +2 -2
  27. queryapigate-0.26.0/queryapigate/console_dist/assets/HomePage-DtOzlurj.js → queryapigate-0.28.0/queryapigate/console_dist/assets/HomePage-CcpGDBK_.js +3 -3
  28. queryapigate-0.28.0/queryapigate/console_dist/assets/Managed-CNWDZi6e.js +1 -0
  29. queryapigate-0.26.0/queryapigate/console_dist/assets/MetricsPage-o3kSXmOq.js → queryapigate-0.28.0/queryapigate/console_dist/assets/MetricsPage-Dxdebop4.js +1 -1
  30. queryapigate-0.28.0/queryapigate/console_dist/assets/RepositoryPage-DmazL70u.js +4 -0
  31. queryapigate-0.26.0/queryapigate/console_dist/assets/Results-xBStmWdp.js → queryapigate-0.28.0/queryapigate/console_dist/assets/Results-BdoTFMC_.js +2 -2
  32. queryapigate-0.28.0/queryapigate/console_dist/assets/RolesPage-Dn-c3szF.js +1 -0
  33. queryapigate-0.26.0/queryapigate/console_dist/assets/SchemaBrowser-DykbAObL.js → queryapigate-0.28.0/queryapigate/console_dist/assets/SchemaBrowser-mmFZRlm_.js +4 -4
  34. queryapigate-0.26.0/queryapigate/console_dist/assets/SettingsPage-mw196Ecu.js → queryapigate-0.28.0/queryapigate/console_dist/assets/SettingsPage-Bi6cE77i.js +2 -2
  35. queryapigate-0.26.0/queryapigate/console_dist/assets/StatTile-5XA5PKWp.js → queryapigate-0.28.0/queryapigate/console_dist/assets/StatTile-DnMgkKp4.js +2 -2
  36. queryapigate-0.26.0/queryapigate/console_dist/assets/Time-zMnS1kSm.js → queryapigate-0.28.0/queryapigate/console_dist/assets/Time-CZQinVk_.js +1 -1
  37. 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
  38. queryapigate-0.28.0/queryapigate/console_dist/assets/forms-qgM6Rab7.js +1 -0
  39. 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
  40. queryapigate-0.26.0/queryapigate/console_dist/assets/index-D9oV4vVP.css → queryapigate-0.28.0/queryapigate/console_dist/assets/index-BLd0t6vZ.css +1 -1
  41. queryapigate-0.26.0/queryapigate/console_dist/assets/index-D0zbyWWM.js → queryapigate-0.28.0/queryapigate/console_dist/assets/index-C7ega31V.js +4 -4
  42. 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
  43. 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
  44. queryapigate-0.28.0/queryapigate/console_dist/assets/shared-DLUzWeB2.js +1 -0
  45. queryapigate-0.26.0/queryapigate/console_dist/assets/useMutation-BCy3Br67.js → queryapigate-0.28.0/queryapigate/console_dist/assets/useMutation-Df0q777p.js +1 -1
  46. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console_dist/index.html +2 -2
  47. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/db.py +82 -4
  48. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/experimental.py +17 -0
  49. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/exports.py +19 -8
  50. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/history.py +2 -0
  51. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/pool.py +16 -5
  52. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/runners.py +14 -5
  53. queryapigate-0.28.0/queryapigate/secretrefs.py +548 -0
  54. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/store.py +52 -41
  55. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/v1.py +58 -1
  56. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/v1_spec.py +78 -0
  57. queryapigate-0.28.0/queryapigate/vault.py +172 -0
  58. {queryapigate-0.26.0 → queryapigate-0.28.0/queryapigate.egg-info}/PKG-INFO +4 -1
  59. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/SOURCES.txt +43 -34
  60. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/requires.txt +4 -0
  61. queryapigate-0.28.0/tests/test_aws.py +213 -0
  62. queryapigate-0.28.0/tests/test_configcode.py +924 -0
  63. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_helm.py +49 -1
  64. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_history.py +18 -3
  65. queryapigate-0.28.0/tests/test_secretrefs.py +270 -0
  66. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_settings.py +1 -1
  67. queryapigate-0.28.0/tests/test_vault.py +406 -0
  68. queryapigate-0.26.0/queryapigate/console_dist/assets/ApiKeysPage-DxEySu_Z.js +0 -1
  69. queryapigate-0.26.0/queryapigate/console_dist/assets/ConnectionsPage-CFCKdJ9-.js +0 -4
  70. queryapigate-0.26.0/queryapigate/console_dist/assets/DesignerPage-DL1ysNb9.js +0 -4
  71. queryapigate-0.26.0/queryapigate/console_dist/assets/DestinationsPage-Du2l56ln.js +0 -1
  72. queryapigate-0.26.0/queryapigate/console_dist/assets/ExportsPage-Cs2BpNim.js +0 -1
  73. queryapigate-0.26.0/queryapigate/console_dist/assets/RepositoryPage-Cooj3jby.js +0 -4
  74. queryapigate-0.26.0/queryapigate/console_dist/assets/RolesPage-CcR_ttpB.js +0 -1
  75. queryapigate-0.26.0/queryapigate/console_dist/assets/forms-C5L_fKDm.js +0 -1
  76. queryapigate-0.26.0/queryapigate/console_dist/assets/shared-DygTbsGs.js +0 -1
  77. {queryapigate-0.26.0 → queryapigate-0.28.0}/LICENSE +0 -0
  78. {queryapigate-0.26.0 → queryapigate-0.28.0}/README.md +0 -0
  79. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/__main__.py +0 -0
  80. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/admins.py +0 -0
  81. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/agentwrites.py +0 -0
  82. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/auditchain.py +0 -0
  83. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/auditstream.py +0 -0
  84. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/broadcast.py +0 -0
  85. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/bundle.py +0 -0
  86. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/cache.py +0 -0
  87. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/collection_admin.py +0 -0
  88. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console.py +0 -0
  89. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
  90. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/console_dist/favicon.svg +0 -0
  91. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/cors.py +0 -0
  92. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/databases.py +0 -0
  93. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/definitions.py +0 -0
  94. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/deprecations.py +0 -0
  95. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/descriptions.py +0 -0
  96. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/destinations.py +0 -0
  97. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/duckfiles.py +0 -0
  98. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/engine.py +0 -0
  99. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/errors.py +0 -0
  100. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/events.py +0 -0
  101. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/examples.py +0 -0
  102. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/exporting.py +0 -0
  103. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/formats.py +0 -0
  104. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/governance.py +0 -0
  105. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/instances.py +0 -0
  106. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/jwtauth.py +0 -0
  107. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
  108. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/limits.py +0 -0
  109. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/logging_setup.py +0 -0
  110. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/masking.py +0 -0
  111. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/mcp_server.py +0 -0
  112. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/metrics.py +0 -0
  113. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/mongotools.py +0 -0
  114. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/oidc.py +0 -0
  115. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/openapi.py +0 -0
  116. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/params.py +0 -0
  117. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/parquet.py +0 -0
  118. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/postman.py +0 -0
  119. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/quotas.py +0 -0
  120. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/ratelimit.py +0 -0
  121. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/rediscache.py +0 -0
  122. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/rekey.py +0 -0
  123. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/schema.py +0 -0
  124. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/secheaders.py +0 -0
  125. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/__init__.py +0 -0
  126. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/access.py +0 -0
  127. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/administrators.py +0 -0
  128. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/audit.py +0 -0
  129. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/collections.py +0 -0
  130. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/connections.py +0 -0
  131. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/descriptions.py +0 -0
  132. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/destinations.py +0 -0
  133. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/exports.py +0 -0
  134. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/mcp.py +0 -0
  135. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/services/queries.py +0 -0
  136. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/sqlflow.py +0 -0
  137. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/sqltools.py +0 -0
  138. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/sso.py +0 -0
  139. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/tableguard.py +0 -0
  140. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/tracing.py +0 -0
  141. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate/usage.py +0 -0
  142. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/dependency_links.txt +0 -0
  143. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/entry_points.txt +0 -0
  144. {queryapigate-0.26.0 → queryapigate-0.28.0}/queryapigate.egg-info/top_level.txt +0 -0
  145. {queryapigate-0.26.0 → queryapigate-0.28.0}/setup.cfg +0 -0
  146. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_adhoc_history.py +0 -0
  147. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_adminroles.py +0 -0
  148. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_admins.py +0 -0
  149. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_agent_writes.py +0 -0
  150. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_alerts.py +0 -0
  151. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_api_administrators.py +0 -0
  152. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_api_keys.py +0 -0
  153. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_api_v1.py +0 -0
  154. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_audit_chain.py +0 -0
  155. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_audit_log.py +0 -0
  156. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_audit_stream.py +0 -0
  157. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_backup_restore.py +0 -0
  158. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_broadcast.py +0 -0
  159. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_cli.py +0 -0
  160. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_collection_bundle.py +0 -0
  161. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_collection_postman.py +0 -0
  162. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_collections.py +0 -0
  163. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_connection_encryption.py +0 -0
  164. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_console.py +0 -0
  165. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_cors_and_rate_limit.py +0 -0
  166. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_databases.py +0 -0
  167. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_db.py +0 -0
  168. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_dependency_floors.py +0 -0
  169. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_deprecations.py +0 -0
  170. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_descriptions.py +0 -0
  171. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_destinations.py +0 -0
  172. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_duckdb.py +0 -0
  173. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_duckdb_files.py +0 -0
  174. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_errors.py +0 -0
  175. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_events.py +0 -0
  176. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_events_server.py +0 -0
  177. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_examples.py +0 -0
  178. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_experimental.py +0 -0
  179. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_export_security.py +0 -0
  180. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_exporting.py +0 -0
  181. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_exports.py +0 -0
  182. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_howto.py +0 -0
  183. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_instances.py +0 -0
  184. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_integration.py +0 -0
  185. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_jdbc.py +0 -0
  186. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_jwt.py +0 -0
  187. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_key_rotation.py +0 -0
  188. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_limits.py +0 -0
  189. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_masking.py +0 -0
  190. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_masking_requests.py +0 -0
  191. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mcp.py +0 -0
  192. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mcp_keycloak.py +0 -0
  193. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mcp_oauth.py +0 -0
  194. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_mongotools.py +0 -0
  195. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_multinode.py +0 -0
  196. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_observability.py +0 -0
  197. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_oidc.py +0 -0
  198. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_params.py +0 -0
  199. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_parquet.py +0 -0
  200. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_pool.py +0 -0
  201. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_publishing.py +0 -0
  202. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_query_window.py +0 -0
  203. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_queryapigate.py +0 -0
  204. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_quotas.py +0 -0
  205. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_rekey.py +0 -0
  206. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_release_check.py +0 -0
  207. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_response_cache.py +0 -0
  208. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_roles.py +0 -0
  209. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_rolling_upgrade.py +0 -0
  210. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_security_headers.py +0 -0
  211. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_shared_rate_limit.py +0 -0
  212. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sql_guard_fuzz.py +0 -0
  213. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sql_guard_writes.py +0 -0
  214. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sqlflow.py +0 -0
  215. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sso.py +0 -0
  216. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_sso_keycloak.py +0 -0
  217. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_tableguard.py +0 -0
  218. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_tracing.py +0 -0
  219. {queryapigate-0.26.0 → queryapigate-0.28.0}/tests/test_upgrades.py +0 -0
  220. {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.26.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[mcp]; python_version>='3.10'",
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
 
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = "0.26.0"
2
+ __version__ = "0.28.0"
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -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(), *_audit_chain(), *_audit_delivery(now), *_writes(),
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
- 'connections': _normalize_connections(connections),
521
- 'allow_writes': bool(allow_writes),
522
- 'queries': _normalize_queries(queries),
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 = (datetime.now() + timedelta(minutes=grace)).strftime('%Y-%m-%d %H:%M:%S') if grace else None
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
- 'connections': _normalize_connections(connections),
689
- 'allow_writes': bool(allow_writes),
690
- 'queries': _normalize_queries(queries),
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()