queryapigate 0.31.0__tar.gz → 0.32.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 (217) hide show
  1. {queryapigate-0.31.0/queryapigate.egg-info → queryapigate-0.32.0}/PKG-INFO +3 -3
  2. {queryapigate-0.31.0 → queryapigate-0.32.0}/README.md +2 -2
  3. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/__init__.py +1 -1
  4. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/auditchain.py +49 -10
  5. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/auditstream.py +2 -1
  6. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/cli.py +8 -1
  7. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/experimental.py +2 -10
  8. {queryapigate-0.31.0 → queryapigate-0.32.0/queryapigate.egg-info}/PKG-INFO +3 -3
  9. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate.egg-info/SOURCES.txt +1 -0
  10. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_audit_chain.py +64 -0
  11. queryapigate-0.32.0/tests/test_audit_siem.py +246 -0
  12. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_experimental.py +12 -8
  13. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_supply_chain.py +9 -0
  14. {queryapigate-0.31.0 → queryapigate-0.32.0}/LICENSE +0 -0
  15. {queryapigate-0.31.0 → queryapigate-0.32.0}/pyproject.toml +0 -0
  16. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/__main__.py +0 -0
  17. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/accesslog.py +0 -0
  18. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/adminroles.py +0 -0
  19. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/admins.py +0 -0
  20. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/agentwrites.py +0 -0
  21. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/alerts.py +0 -0
  22. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/apikeys.py +0 -0
  23. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/app.py +0 -0
  24. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/aws.py +0 -0
  25. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/broadcast.py +0 -0
  26. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/bundle.py +0 -0
  27. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/cache.py +0 -0
  28. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/collection_admin.py +0 -0
  29. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/config.py +0 -0
  30. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/configcode.py +0 -0
  31. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console.py +0 -0
  32. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/AccessMapPage-CFAKvNHS.js +0 -0
  33. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/AdministratorsPage-O9o_8T8Q.js +0 -0
  34. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/AgentsPage-CJL7R9ML.js +0 -0
  35. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/AlertsPage-Rhnx0WE5.js +0 -0
  36. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/ApiKeysPage-LSfT7OFf.js +0 -0
  37. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/ApprovalsPage-CxyRU_om.js +0 -0
  38. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/AuditLogPage-CfozUklt.js +0 -0
  39. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/CachingPage-8TY7yUvl.js +0 -0
  40. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/CodeBox-B8iq2Oop.js +0 -0
  41. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/ConnectionsPage-CrgOBe8i.js +0 -0
  42. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/DesignerPage-C7m2WgjH.js +0 -0
  43. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/DestinationsPage-hjd4ejdt.js +0 -0
  44. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/ExportsPage-D70vuQNl.js +0 -0
  45. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/HelpPage-C7vakDSS.js +0 -0
  46. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/HomePage-BuExjOpj.js +0 -0
  47. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/Managed-DjB6h9Kc.js +0 -0
  48. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/MetricsPage-BAQxP5-4.js +0 -0
  49. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/RepositoryPage-8CBmviv-.js +0 -0
  50. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/Results-DofTMPkj.js +0 -0
  51. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/RolesPage-CH_Dr1DP.js +0 -0
  52. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/SchemaBrowser-D57iE2Kg.js +0 -0
  53. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/SettingsPage-CHmKfYHu.js +0 -0
  54. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/StatTile-BBd2i5mC.js +0 -0
  55. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/Time-CR17Ozn9.js +0 -0
  56. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/forms-C3dw2wG6.js +0 -0
  57. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/forms-DR5CXITG.js +0 -0
  58. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/icons-C-i_90Gs.js +0 -0
  59. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/index-BLd0t6vZ.css +0 -0
  60. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/index-BNMV1syF.js +0 -0
  61. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/maskRules-DyD2pxp7.js +0 -0
  62. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/reach-0Yvn3NAt.js +0 -0
  63. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/shared-BebZOZXs.js +0 -0
  64. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
  65. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/assets/useMutation-DCzNFnov.js +0 -0
  66. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/favicon.svg +0 -0
  67. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/console_dist/index.html +0 -0
  68. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/cors.py +0 -0
  69. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/databases.py +0 -0
  70. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/db.py +0 -0
  71. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/definitions.py +0 -0
  72. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/deprecations.py +0 -0
  73. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/descriptions.py +0 -0
  74. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/destinations.py +0 -0
  75. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/duckfiles.py +0 -0
  76. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/engine.py +0 -0
  77. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/errors.py +0 -0
  78. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/events.py +0 -0
  79. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/examples.py +0 -0
  80. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/exporting.py +0 -0
  81. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/exports.py +0 -0
  82. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/formats.py +0 -0
  83. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/governance.py +0 -0
  84. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/gunicorn_conf.py +0 -0
  85. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/history.py +0 -0
  86. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/instances.py +0 -0
  87. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/jwtauth.py +0 -0
  88. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
  89. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/limits.py +0 -0
  90. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/logging_setup.py +0 -0
  91. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/masking.py +0 -0
  92. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/mcp_server.py +0 -0
  93. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/metrics.py +0 -0
  94. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/mongotools.py +0 -0
  95. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/oidc.py +0 -0
  96. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/openapi.py +0 -0
  97. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/params.py +0 -0
  98. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/parquet.py +0 -0
  99. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/pool.py +0 -0
  100. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/postman.py +0 -0
  101. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/quotas.py +0 -0
  102. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/ratelimit.py +0 -0
  103. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/rediscache.py +0 -0
  104. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/rekey.py +0 -0
  105. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/runners.py +0 -0
  106. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/schema.py +0 -0
  107. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/secheaders.py +0 -0
  108. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/secretrefs.py +0 -0
  109. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/__init__.py +0 -0
  110. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/access.py +0 -0
  111. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/administrators.py +0 -0
  112. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/audit.py +0 -0
  113. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/collections.py +0 -0
  114. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/connections.py +0 -0
  115. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/descriptions.py +0 -0
  116. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/destinations.py +0 -0
  117. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/exports.py +0 -0
  118. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/mcp.py +0 -0
  119. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/services/queries.py +0 -0
  120. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/sqlflow.py +0 -0
  121. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/sqltools.py +0 -0
  122. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/sso.py +0 -0
  123. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/store.py +0 -0
  124. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/tableguard.py +0 -0
  125. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/tracing.py +0 -0
  126. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/usage.py +0 -0
  127. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/v1.py +0 -0
  128. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/v1_spec.py +0 -0
  129. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate/vault.py +0 -0
  130. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate.egg-info/dependency_links.txt +0 -0
  131. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate.egg-info/entry_points.txt +0 -0
  132. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate.egg-info/requires.txt +0 -0
  133. {queryapigate-0.31.0 → queryapigate-0.32.0}/queryapigate.egg-info/top_level.txt +0 -0
  134. {queryapigate-0.31.0 → queryapigate-0.32.0}/setup.cfg +0 -0
  135. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_adhoc_history.py +0 -0
  136. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_adminroles.py +0 -0
  137. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_admins.py +0 -0
  138. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_agent_writes.py +0 -0
  139. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_alerts.py +0 -0
  140. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_api_administrators.py +0 -0
  141. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_api_keys.py +0 -0
  142. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_api_v1.py +0 -0
  143. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_audit_log.py +0 -0
  144. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_audit_stream.py +0 -0
  145. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_aws.py +0 -0
  146. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_backup_restore.py +0 -0
  147. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_broadcast.py +0 -0
  148. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_cli.py +0 -0
  149. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_collection_bundle.py +0 -0
  150. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_collection_postman.py +0 -0
  151. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_collections.py +0 -0
  152. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_configcode.py +0 -0
  153. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_connection_encryption.py +0 -0
  154. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_console.py +0 -0
  155. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_cors_and_rate_limit.py +0 -0
  156. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_data_protection.py +0 -0
  157. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_databases.py +0 -0
  158. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_db.py +0 -0
  159. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_dependency_floors.py +0 -0
  160. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_deprecations.py +0 -0
  161. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_descriptions.py +0 -0
  162. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_destinations.py +0 -0
  163. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_docs_site.py +0 -0
  164. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_duckdb.py +0 -0
  165. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_duckdb_files.py +0 -0
  166. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_errors.py +0 -0
  167. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_events.py +0 -0
  168. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_events_server.py +0 -0
  169. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_examples.py +0 -0
  170. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_export_security.py +0 -0
  171. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_exporting.py +0 -0
  172. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_exports.py +0 -0
  173. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_fips.py +0 -0
  174. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_helm.py +0 -0
  175. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_history.py +0 -0
  176. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_howto.py +0 -0
  177. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_instances.py +0 -0
  178. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_integration.py +0 -0
  179. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_jdbc.py +0 -0
  180. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_jwt.py +0 -0
  181. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_key_rotation.py +0 -0
  182. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_limits.py +0 -0
  183. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_masking.py +0 -0
  184. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_masking_requests.py +0 -0
  185. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_mcp.py +0 -0
  186. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_mcp_keycloak.py +0 -0
  187. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_mcp_oauth.py +0 -0
  188. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_mongotools.py +0 -0
  189. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_multinode.py +0 -0
  190. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_observability.py +0 -0
  191. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_oidc.py +0 -0
  192. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_params.py +0 -0
  193. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_parquet.py +0 -0
  194. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_pool.py +0 -0
  195. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_publishing.py +0 -0
  196. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_query_window.py +0 -0
  197. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_queryapigate.py +0 -0
  198. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_quotas.py +0 -0
  199. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_rekey.py +0 -0
  200. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_release_check.py +0 -0
  201. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_response_cache.py +0 -0
  202. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_roles.py +0 -0
  203. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_rolling_upgrade.py +0 -0
  204. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_secretrefs.py +0 -0
  205. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_security_headers.py +0 -0
  206. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_settings.py +0 -0
  207. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_shared_rate_limit.py +0 -0
  208. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_sql_guard_fuzz.py +0 -0
  209. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_sql_guard_writes.py +0 -0
  210. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_sqlflow.py +0 -0
  211. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_sso.py +0 -0
  212. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_sso_keycloak.py +0 -0
  213. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_tableguard.py +0 -0
  214. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_tracing.py +0 -0
  215. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_upgrades.py +0 -0
  216. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_usage.py +0 -0
  217. {queryapigate-0.31.0 → queryapigate-0.32.0}/tests/test_vault.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.31.0
3
+ Version: 0.32.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
@@ -667,8 +667,8 @@ Everything is configured through environment variables (all optional):
667
667
  | `QUERYAPIGATE_CORS_ORIGINS` | unset | Websites allowed to call the API from a browser: comma-separated origins such as `https://app.example.com`, or `*`. Off by default. |
668
668
  | `QUERYAPIGATE_RATE_LIMIT` | unset | Requests allowed per client address, e.g. `60/minute` (also `second`, `hour`, `day`). Off by default; a malformed value stops startup. |
669
669
  | `QUERYAPIGATE_TRUST_PROXY` | `0` | Number of reverse proxies in front of the app whose `X-Forwarded-*` headers are trusted. Set it (usually `1`) behind nginx, a load balancer or a platform router, or every client looks like the proxy. |
670
- | `QUERYAPIGATE_AUDIT_SYSLOG_URL` | *(unset)* | Stream every audit entry to a SIEM over syslog: `tls://host:6514` or `tcp://host:514` (experimental). With `QUERYAPIGATE_AUDIT_SYSLOG_CA`, and `_CERT`/`_KEY` for mutual TLS. |
671
- | `QUERYAPIGATE_AUDIT_OTLP` | `false` | Stream every audit entry as OTLP log records to the OTLP logs endpoint (experimental). |
670
+ | `QUERYAPIGATE_AUDIT_SYSLOG_URL` | *(unset)* | Stream every audit entry to a SIEM over syslog: `tls://host:6514` or `tcp://host:514`. With `QUERYAPIGATE_AUDIT_SYSLOG_CA`, and `_CERT`/`_KEY` for mutual TLS. |
671
+ | `QUERYAPIGATE_AUDIT_OTLP` | `false` | Stream every audit entry as OTLP log records to the OTLP logs endpoint. |
672
672
  | `QUERYAPIGATE_AUDIT_KEY` | *(unset)* | HMAC key (32+ characters) the audit log's hash chain is signed with, so an edit made with only the store can't be re-signed. Several, comma-separated, while rotating it. Unset: plain SHA-256. |
673
673
  | `QUERYAPIGATE_HSTS_MAX_AGE` | *(unset)* | Seconds browsers should use only HTTPS for this host (`Strict-Transport-Security`), sent only on requests that arrived over HTTPS. Unset: not sent - set it once every client reaches the server over HTTPS. |
674
674
  | `QUERYAPIGATE_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
@@ -589,8 +589,8 @@ Everything is configured through environment variables (all optional):
589
589
  | `QUERYAPIGATE_CORS_ORIGINS` | unset | Websites allowed to call the API from a browser: comma-separated origins such as `https://app.example.com`, or `*`. Off by default. |
590
590
  | `QUERYAPIGATE_RATE_LIMIT` | unset | Requests allowed per client address, e.g. `60/minute` (also `second`, `hour`, `day`). Off by default; a malformed value stops startup. |
591
591
  | `QUERYAPIGATE_TRUST_PROXY` | `0` | Number of reverse proxies in front of the app whose `X-Forwarded-*` headers are trusted. Set it (usually `1`) behind nginx, a load balancer or a platform router, or every client looks like the proxy. |
592
- | `QUERYAPIGATE_AUDIT_SYSLOG_URL` | *(unset)* | Stream every audit entry to a SIEM over syslog: `tls://host:6514` or `tcp://host:514` (experimental). With `QUERYAPIGATE_AUDIT_SYSLOG_CA`, and `_CERT`/`_KEY` for mutual TLS. |
593
- | `QUERYAPIGATE_AUDIT_OTLP` | `false` | Stream every audit entry as OTLP log records to the OTLP logs endpoint (experimental). |
592
+ | `QUERYAPIGATE_AUDIT_SYSLOG_URL` | *(unset)* | Stream every audit entry to a SIEM over syslog: `tls://host:6514` or `tcp://host:514`. With `QUERYAPIGATE_AUDIT_SYSLOG_CA`, and `_CERT`/`_KEY` for mutual TLS. |
593
+ | `QUERYAPIGATE_AUDIT_OTLP` | `false` | Stream every audit entry as OTLP log records to the OTLP logs endpoint. |
594
594
  | `QUERYAPIGATE_AUDIT_KEY` | *(unset)* | HMAC key (32+ characters) the audit log's hash chain is signed with, so an edit made with only the store can't be re-signed. Several, comma-separated, while rotating it. Unset: plain SHA-256. |
595
595
  | `QUERYAPIGATE_HSTS_MAX_AGE` | *(unset)* | Seconds browsers should use only HTTPS for this host (`Strict-Transport-Security`), sent only on requests that arrived over HTTPS. Unset: not sent - set it once every client reaches the server over HTTPS. |
596
596
  | `QUERYAPIGATE_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = "0.31.0"
2
+ __version__ = "0.32.0"
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -165,25 +165,64 @@ def verify(conn):
165
165
  return report
166
166
 
167
167
 
168
+ def _entry_in(line):
169
+ """The JSON object a line holds: the whole line (an export file), or the message at its end - a syslog line, as
170
+ rsyslog or syslog-ng write what the stream sent (`<110>1 ... [meta sequenceId="7"] {...}`). None if it has none."""
171
+ try:
172
+ data = json.loads(line)
173
+ return data if isinstance(data, dict) else None
174
+ except ValueError:
175
+ pass
176
+ decoder = json.JSONDecoder()
177
+ start = line.find('{')
178
+ while start != -1:
179
+ try:
180
+ data, end = decoder.raw_decode(line, start)
181
+ except ValueError:
182
+ start = line.find('{', start + 1)
183
+ continue
184
+ if isinstance(data, dict) and not line[end:].strip():
185
+ return data
186
+ start = line.find('{', start + 1)
187
+ return None
188
+
189
+
168
190
  def verify_file(path):
169
- """An export file (QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE), one entry per line with its chain fields - from its first
170
- line, whose `prev_hash` is taken as given (the file may have started after the chain did)."""
171
- links = []
191
+ """A copy of the chain, one entry per line - an export file (QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE), or what a SIEM
192
+ received (ADR 0019): JSON lines, or syslog lines whose message is the entry. Walked in `seq` order from its first
193
+ entry, whose `prev_hash` is taken as given (the copy may have started after the chain did).
194
+
195
+ Delivery is at least once, so a SIEM's copy may hold an entry twice: an exact repeat - the same `seq` and `hash` -
196
+ is dropped, and counted (`repeats`). A repeat whose `hash` differs is never dropped: `conflicting_copies`, naming
197
+ both - one of them isn't what was written."""
198
+ by_seq = {}
199
+ repeats = 0
172
200
  with open(path) as f:
173
201
  for number, line in enumerate(f, 1):
174
202
  if not line.strip():
175
203
  continue
176
- try:
177
- data = json.loads(line)
178
- except ValueError:
204
+ data = _entry_in(line)
205
+ if data is None:
179
206
  return {'intact': False, 'break': {'line': number, 'problem': 'not_json'}}
180
207
  if 'seq' not in data:
181
208
  continue # written before the chain existed
182
- chain = (data.pop('seq'), data.pop('prev_hash', None), data.pop('hash', None))
183
- links.append((*chain, data))
209
+ seq, prev_hash, digest = data.pop('seq'), data.pop('prev_hash', None), data.pop('hash', None)
210
+ seen = by_seq.get(seq)
211
+ if seen is None:
212
+ by_seq[seq] = (seq, prev_hash, digest, data, number)
213
+ continue
214
+ if (seen[1], seen[2], seen[3]) == (prev_hash, digest, data):
215
+ repeats += 1
216
+ continue
217
+ return {'intact': False, 'break': {'seq': seq, 'timestamp': data.get('timestamp'),
218
+ 'action': data.get('action'), 'problem': 'conflicting_copies',
219
+ 'lines': [seen[4], number]}}
220
+ links = [link[:4] for _seq, link in sorted(by_seq.items())]
184
221
  if not links:
185
- return {'intact': True, 'entries': 0}
186
- return _walk(links, (links[0][0] - 1, links[0][1]))
222
+ return {'intact': True, 'entries': 0, 'repeats': repeats}
223
+ report = _walk(links, (links[0][0] - 1, links[0][1]))
224
+ report['repeats'] = repeats
225
+ return report
187
226
 
188
227
 
189
228
  def head_keyed(conn):
@@ -1,4 +1,5 @@
1
- """The audit log, streamed to a SIEM (ADR 0012, BACKLOG #96) - experimental.
1
+ """The audit log, streamed to a SIEM (ADR 0012, BACKLOG #96) - stable since 0.32, tested against real receivers
2
+ (ADR 0019).
2
3
 
3
4
  Two outputs, each turned on by one setting:
4
5
 
@@ -716,12 +716,17 @@ def _audit_verify(args):
716
716
  how = ('keyed with QUERYAPIGATE_AUDIT_KEY' if report.get('keyed')
717
717
  else 'unkeyed (plain SHA-256: set QUERYAPIGATE_AUDIT_KEY so an edit cannot be re-signed)')
718
718
  print(f"Intact: entries {report['first_seq']} to {report['last_seq']}, {how}.")
719
+ if report.get('repeats'):
720
+ print(f" {report['repeats']} repeated entries dropped - the same seq and hash, as at-least-once delivery "
721
+ 'can send them')
719
722
  if report.get('unchecked_without_key'):
720
723
  print(f" {report['unchecked_without_key']} keyed entries not checked - QUERYAPIGATE_AUDIT_KEY is not set")
721
724
  return 0
722
725
  found = report['break']
723
726
  where = f"line {found['line']}" if 'line' in found else (
724
727
  f"entry {found['seq']} ({found.get('action')}, {found.get('timestamp')})")
728
+ if found['problem'] == 'conflicting_copies':
729
+ where += f" - lines {found['lines'][0]} and {found['lines'][1]} differ"
725
730
  print(f"BROKEN at {where}: {found['problem'].replace('_', ' ')}", file=sys.stderr)
726
731
  return 1
727
732
 
@@ -915,7 +920,9 @@ def build_parser():
915
920
  audit_actions = audit_parser.add_subparsers(dest='action')
916
921
  audit_verify = audit_actions.add_parser('verify', help='walk the chain and say whether it is intact, or where it '
917
922
  'first breaks (exit 0 or 1)')
918
- audit_verify.add_argument('--file', help='an export file (QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE) instead of the store')
923
+ audit_verify.add_argument('--file', help='a copy instead of the store: an export file '
924
+ '(QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE), or what a SIEM received - JSON or '
925
+ 'syslog lines, repeats allowed')
919
926
  audit_verify.set_defaults(func=_audit_verify)
920
927
 
921
928
  history_parser = commands.add_parser('history', help='run history: forget a person (ADR 0016)')
@@ -9,8 +9,8 @@ docs say so at each feature's own section.
9
9
  A feature graduates - its entry deleted here, its callout replaced by a note that it's stable and what may still be
10
10
  added - when it meets every one of GRADUATION. Alerts, remote files through DuckDB, exports and column masking did in
11
11
  0.20; descriptions, agent usage, usage quotas, configuration as code, OAuth for the MCP server, write tools and
12
- Console sign-in through SSO in 0.30 (ADR 0017), with the Helm chart. The connection types have their own terms
13
- (databases.GRADUATION).
12
+ Console sign-in through SSO in 0.30 (ADR 0017), with the Helm chart; the audit log streamed to a SIEM in 0.32, once
13
+ tested against real receivers (ADR 0019). The connection types have their own terms (databases.GRADUATION).
14
14
  """
15
15
  from typing import Any
16
16
 
@@ -41,14 +41,6 @@ FEATURES: dict[str, dict[str, Any]] = {
41
41
  'db_types': ('h2', 'jdbc', 'mongo'),
42
42
  'docs': ('documentation/DATABASE_CONNECTION_CONFIGURATION.md', 'Experimental: h2, jdbc and mongo'),
43
43
  },
44
- 'audit_stream': {
45
- 'name': 'The audit log streamed to a SIEM',
46
- 'why': 'new in 0.25 - its settings and message format may change as it meets real SIEMs (ADR 0012)',
47
- 'settings': ('QUERYAPIGATE_AUDIT_SYSLOG_URL', 'QUERYAPIGATE_AUDIT_SYSLOG_CA', 'QUERYAPIGATE_AUDIT_SYSLOG_CERT',
48
- 'QUERYAPIGATE_AUDIT_SYSLOG_KEY', 'QUERYAPIGATE_AUDIT_OTLP'),
49
- 'operations': (('get', '/api/v1/audit/delivery'),),
50
- 'docs': ('documentation/API.md', 'Streaming the audit log to a SIEM'),
51
- },
52
44
  'tracing': {
53
45
  'name': 'Tracing with OpenTelemetry',
54
46
  'why': 'new in 0.23 - span names and attributes may change as the OpenTelemetry conventions for databases and '
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.31.0
3
+ Version: 0.32.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
@@ -667,8 +667,8 @@ Everything is configured through environment variables (all optional):
667
667
  | `QUERYAPIGATE_CORS_ORIGINS` | unset | Websites allowed to call the API from a browser: comma-separated origins such as `https://app.example.com`, or `*`. Off by default. |
668
668
  | `QUERYAPIGATE_RATE_LIMIT` | unset | Requests allowed per client address, e.g. `60/minute` (also `second`, `hour`, `day`). Off by default; a malformed value stops startup. |
669
669
  | `QUERYAPIGATE_TRUST_PROXY` | `0` | Number of reverse proxies in front of the app whose `X-Forwarded-*` headers are trusted. Set it (usually `1`) behind nginx, a load balancer or a platform router, or every client looks like the proxy. |
670
- | `QUERYAPIGATE_AUDIT_SYSLOG_URL` | *(unset)* | Stream every audit entry to a SIEM over syslog: `tls://host:6514` or `tcp://host:514` (experimental). With `QUERYAPIGATE_AUDIT_SYSLOG_CA`, and `_CERT`/`_KEY` for mutual TLS. |
671
- | `QUERYAPIGATE_AUDIT_OTLP` | `false` | Stream every audit entry as OTLP log records to the OTLP logs endpoint (experimental). |
670
+ | `QUERYAPIGATE_AUDIT_SYSLOG_URL` | *(unset)* | Stream every audit entry to a SIEM over syslog: `tls://host:6514` or `tcp://host:514`. With `QUERYAPIGATE_AUDIT_SYSLOG_CA`, and `_CERT`/`_KEY` for mutual TLS. |
671
+ | `QUERYAPIGATE_AUDIT_OTLP` | `false` | Stream every audit entry as OTLP log records to the OTLP logs endpoint. |
672
672
  | `QUERYAPIGATE_AUDIT_KEY` | *(unset)* | HMAC key (32+ characters) the audit log's hash chain is signed with, so an edit made with only the store can't be re-signed. Several, comma-separated, while rotating it. Unset: plain SHA-256. |
673
673
  | `QUERYAPIGATE_HSTS_MAX_AGE` | *(unset)* | Seconds browsers should use only HTTPS for this host (`Strict-Transport-Security`), sent only on requests that arrived over HTTPS. Unset: not sent - set it once every client reaches the server over HTTPS. |
674
674
  | `QUERYAPIGATE_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
@@ -136,6 +136,7 @@ tests/test_api_keys.py
136
136
  tests/test_api_v1.py
137
137
  tests/test_audit_chain.py
138
138
  tests/test_audit_log.py
139
+ tests/test_audit_siem.py
139
140
  tests/test_audit_stream.py
140
141
  tests/test_aws.py
141
142
  tests/test_backup_restore.py
@@ -233,6 +233,70 @@ class ExportFileTests(ChainTestCase):
233
233
  self.assertEqual(auditchain.verify_file(path)['break']['problem'], 'changed')
234
234
 
235
235
 
236
+ class SiemCopyTests(ChainTestCase):
237
+ """What a SIEM received (ADR 0019): delivery is at least once, so its copy may hold an entry twice, and after a
238
+ retry not in order - an untouched copy still verifies; a copy someone edited still doesn't."""
239
+
240
+ def copy(self, lines, name='siem.log'):
241
+ path = os.path.join(self.tmp.name, name)
242
+ with open(path, 'w') as f:
243
+ f.writelines(line + '\n' for line in lines)
244
+ return path
245
+
246
+ def entries(self, n=5):
247
+ path = os.path.join(self.tmp.name, 'audit.jsonl')
248
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE': path}):
249
+ self.write(n)
250
+ return [line.rstrip('\n') for line in open(path)]
251
+
252
+ @staticmethod
253
+ def as_syslog(line):
254
+ entry = json.loads(line)
255
+ return (f'2026-10-13T10:00:00+00:00 api-1 queryapigate[42]: <110>1 2026-10-13T10:00:00Z api-1 queryapigate 42 '
256
+ f'{entry["action"]} [meta sequenceId="{entry["seq"]}"] {line}')
257
+
258
+ def test_exact_repeats_are_dropped_and_counted(self):
259
+ lines = self.entries()
260
+ report = auditchain.verify_file(self.copy(lines[:3] + lines[1:3] + lines[3:])) # 2 and 3 sent again
261
+ self.assertTrue(report['intact'], report)
262
+ self.assertEqual((report['first_seq'], report['last_seq'], report['repeats']), (1, 5, 2))
263
+
264
+ def test_entries_are_walked_in_seq_order_whatever_order_they_arrived_in(self):
265
+ lines = self.entries()
266
+ self.assertTrue(auditchain.verify_file(self.copy([lines[0], lines[3], lines[1], lines[2], lines[4]]))['intact'])
267
+
268
+ def test_a_repeat_that_differs_is_never_dropped(self):
269
+ lines = self.entries()
270
+ forged = json.loads(lines[2])
271
+ forged['target'] = 'forged'
272
+ report = auditchain.verify_file(self.copy([*lines, json.dumps(forged)]))
273
+ self.assertFalse(report['intact'])
274
+ self.assertEqual(report['break']['problem'], 'conflicting_copies')
275
+ self.assertEqual((report['break']['seq'], report['break']['lines']), (3, [3, 6]))
276
+
277
+ def test_syslog_lines_as_a_receiver_writes_them(self):
278
+ lines = [self.as_syslog(line) for line in self.entries()]
279
+ self.assertTrue(auditchain.verify_file(self.copy(lines + lines[:1]))['intact'])
280
+ edited = lines[1].replace('"seq": 2', '"seq": 2, "note": "added"')
281
+ self.assertNotEqual(edited, lines[1])
282
+ report = auditchain.verify_file(self.copy([lines[0], edited, *lines[2:]]))
283
+ self.assertEqual(report['break']['problem'], 'changed')
284
+
285
+ def test_a_missing_entry_is_still_missing(self):
286
+ lines = self.entries()
287
+ report = auditchain.verify_file(self.copy(lines[:2] + lines[3:]))
288
+ self.assertEqual((report['break']['problem'], report['break']['seq']), ('missing', 4))
289
+
290
+ def test_the_cli_says_how_many_repeats_it_dropped(self):
291
+ lines = self.entries(3)
292
+ path = self.copy(lines + lines[1:2])
293
+ out, err = io.StringIO(), io.StringIO()
294
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
295
+ self.assertEqual(cli.main(['audit', 'verify', '--file', path]), 0)
296
+ self.assertIn('Intact: entries 1 to 3', out.getvalue())
297
+ self.assertIn('1 repeated entries dropped', out.getvalue())
298
+
299
+
236
300
  class SurfaceTests(ChainTestCase):
237
301
  def run_cli(self, *argv):
238
302
  out, err = io.StringIO(), io.StringIO()
@@ -0,0 +1,246 @@
1
+ """The audit log's stream against real receivers (ADR 0019): rsyslog and syslog-ng taking RFC 5425 syslog over mutual
2
+ TLS, and an OpenTelemetry Collector taking OTLP/HTTP logs - started by .github/scripts/siem_receivers.sh, which
3
+ QUERYAPIGATE_TEST_SIEM names the directory of. Each test reads what the receiver itself parsed and wrote, never our own
4
+ parse: MSGID, structured data, the message - and checks that copy with `audit verify --file`, as an auditor would.
5
+
6
+ CI's integration job runs these with every receiver up, and fails if any test skips."""
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ import time
12
+ import unittest
13
+ import uuid
14
+ from unittest import mock
15
+
16
+ from queryapigate import auditchain, auditstream, db, store
17
+
18
+ SIEM = os.environ.get('QUERYAPIGATE_TEST_SIEM')
19
+ RECEIVERS = json.load(open(os.path.join(SIEM, 'receivers.json'))) if SIEM else {}
20
+ SKIP = 'set QUERYAPIGATE_TEST_SIEM to the directory .github/scripts/siem_receivers.sh started'
21
+
22
+
23
+ def _docker(*args):
24
+ subprocess.run(['docker', *args], check=True, capture_output=True, timeout=120)
25
+
26
+
27
+ class SiemTestCase(unittest.TestCase):
28
+ receiver = None # 'rsyslog', 'syslog-ng' or 'otelcol'
29
+
30
+ def setUp(self):
31
+ self.tmp = tempfile.TemporaryDirectory()
32
+ self.addCleanup(self.tmp.cleanup)
33
+ self.marker = uuid.uuid4().hex[:12] # every receiver's file is shared between tests: ours are marked
34
+ certs = RECEIVERS['certs']
35
+ env = {'QUERYAPIGATE_HOME': self.tmp.name, 'QUERYAPIGATE_AUDIT_KEY': '', 'QUERYAPIGATE_AUDIT_LOG_LIMIT': '500',
36
+ 'QUERYAPIGATE_AUDIT_SYSLOG_URL': '', 'QUERYAPIGATE_AUDIT_OTLP': '',
37
+ 'QUERYAPIGATE_AUDIT_SYSLOG_CA': f'{certs}/ca.pem',
38
+ 'QUERYAPIGATE_AUDIT_SYSLOG_CERT': f'{certs}/client.pem',
39
+ 'QUERYAPIGATE_AUDIT_SYSLOG_KEY': f'{certs}/client.key'}
40
+ if self.receiver == 'otelcol':
41
+ env.update(QUERYAPIGATE_AUDIT_OTLP='1', OTEL_EXPORTER_OTLP_ENDPOINT=RECEIVERS['otelcol']['url'],
42
+ OTEL_EXPORTER_OTLP_LOGS_ENDPOINT='', OTEL_EXPORTER_OTLP_HEADERS='')
43
+ else:
44
+ env['QUERYAPIGATE_AUDIT_SYSLOG_URL'] = RECEIVERS[self.receiver]['url']
45
+ patcher = mock.patch.dict(os.environ, env)
46
+ patcher.start()
47
+ self.addCleanup(patcher.stop)
48
+ auditstream.AUTO_START = False
49
+ self.addCleanup(setattr, auditstream, 'AUTO_START', True)
50
+ self.addCleanup(self._close_senders)
51
+ self._close_senders()
52
+ db.init_schema()
53
+
54
+ @staticmethod
55
+ def _close_senders():
56
+ for sender in auditstream._senders.values():
57
+ sender.close()
58
+ auditstream._senders.clear()
59
+
60
+ @property
61
+ def output(self):
62
+ return 'otlp' if self.receiver == 'otelcol' else 'syslog'
63
+
64
+ def write(self, count, start=0):
65
+ for i in range(start, start + count):
66
+ store.record_audit('alice', 'update_key', f'{self.marker}-key-{i}', {'n': i})
67
+
68
+ def deliver(self):
69
+ return auditstream.deliver_once(self.output)
70
+
71
+ def received(self, count, timeout=30.0):
72
+ """What the receiver wrote of this test's entries, once it has `count` - [(app, msgid, sd, entry)] for syslog,
73
+ [(record attributes, entry)] for the Collector."""
74
+ deadline = time.monotonic() + timeout
75
+ found = []
76
+ while time.monotonic() < deadline:
77
+ found = self._read()
78
+ if len(found) >= count:
79
+ return found
80
+ time.sleep(0.25)
81
+ self.fail(f'{self.receiver} wrote {len(found)} of {count} entries in {timeout:.0f}s')
82
+
83
+ def _read(self):
84
+ path = RECEIVERS[self.receiver]['out']
85
+ if not os.path.exists(path):
86
+ return []
87
+ with open(path, encoding='utf-8') as f:
88
+ lines = f.read().splitlines()
89
+ if self.receiver == 'otelcol':
90
+ records = []
91
+ for line in lines:
92
+ for resource in json.loads(line)['resourceLogs']:
93
+ for scope in resource['scopeLogs']:
94
+ for record in scope['logRecords']:
95
+ entry = json.loads(record['body']['stringValue'])
96
+ if entry['target'].startswith(self.marker):
97
+ attributes = {a['key']: next(iter(a['value'].values())) for a in record['attributes']}
98
+ records.append((attributes, entry))
99
+ return records
100
+ rows = []
101
+ for line in lines:
102
+ app, msgid, sd, message = line.split('\t', 3)
103
+ entry = json.loads(message.lstrip(' '))
104
+ if entry['target'].startswith(self.marker):
105
+ rows.append((app, msgid, sd, entry))
106
+ return rows
107
+
108
+ def copy_as_received(self, rows, edit=None):
109
+ """The receiver's copy of this test's entries, as a file `audit verify --file` reads - one entry a line."""
110
+ path = os.path.join(self.tmp.name, f'{self.receiver}-copy.jsonl')
111
+ with open(path, 'w') as f:
112
+ for row in rows:
113
+ entry = dict(row[-1])
114
+ if edit is not None and entry['seq'] == edit:
115
+ entry['changes'] = {'n': 'forged'}
116
+ f.write(json.dumps(entry) + '\n')
117
+ return path
118
+
119
+
120
+ class SyslogReceiverTests:
121
+ """For rsyslog and syslog-ng alike."""
122
+
123
+ def test_every_entry_arrives_in_order_parsed_as_meant(self):
124
+ self.write(5)
125
+ self.assertEqual(self.deliver(), 5)
126
+ rows = self.received(5)
127
+ self.assertEqual([r[3]['seq'] for r in rows], [1, 2, 3, 4, 5])
128
+ for app, msgid, sd, entry in rows:
129
+ self.assertEqual(app, 'queryapigate')
130
+ self.assertEqual(msgid, 'update_key') # the action, as the receiver parsed it
131
+ self.assertEqual(sd, f'[meta sequenceId="{entry["seq"]}"]')
132
+ stored = [r[0] for r in db.connection().execute('SELECT hash FROM audit_log ORDER BY seq')]
133
+ self.assertEqual([r[3]['hash'] for r in rows], stored)
134
+
135
+ def test_its_copy_verifies_and_an_edit_in_it_is_found(self):
136
+ self.write(4)
137
+ self.deliver()
138
+ rows = self.received(4)
139
+ report = auditchain.verify_file(self.copy_as_received(rows))
140
+ self.assertEqual((report['intact'], report['first_seq'], report['last_seq']), (True, 1, 4), report)
141
+ broken = auditchain.verify_file(self.copy_as_received(rows, edit=3))
142
+ self.assertEqual((broken['break']['problem'], broken['break']['seq']), ('changed', 3))
143
+
144
+ def test_without_a_client_certificate_it_refuses_and_nothing_is_lost(self):
145
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_AUDIT_SYSLOG_CERT': '', 'QUERYAPIGATE_AUDIT_SYSLOG_KEY': ''}):
146
+ self.write(2)
147
+ with self.assertRaises(OSError) as caught:
148
+ self.deliver()
149
+ self.assertIn('CERTIFICATE_REQUIRED', str(caught.exception))
150
+ [status] = auditstream.status()
151
+ self.assertEqual((status['delivered_seq'], status['behind']), (0, 2))
152
+ self.assertIn('CERTIFICATE_REQUIRED', status['last_error'])
153
+ self._close_senders()
154
+ self.assertEqual(self.deliver(), 2) # with its certificate again: both, nothing lost
155
+ self.assertEqual([r[3]['seq'] for r in self.received(2)], [1, 2])
156
+
157
+ def test_a_client_certificate_from_another_ca_is_refused(self):
158
+ certs = RECEIVERS['certs']
159
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_AUDIT_SYSLOG_CERT': f'{certs}/stranger.pem',
160
+ 'QUERYAPIGATE_AUDIT_SYSLOG_KEY': f'{certs}/stranger.key'}):
161
+ self.write(1)
162
+ with self.assertRaises(OSError) as caught:
163
+ self.deliver()
164
+ self.assertIn('UNKNOWN_CA', str(caught.exception))
165
+ self.assertEqual(auditstream.status()[0]['delivered_seq'], 0)
166
+
167
+ def test_an_outage_loses_nothing(self):
168
+ container = RECEIVERS[self.receiver]['container']
169
+ self.write(2)
170
+ self.assertEqual(self.deliver(), 2)
171
+ self.received(2)
172
+ _docker('stop', container) # QueryAPIGate's connection to it still open: noticed, not written into
173
+ try:
174
+ self.write(3, start=2)
175
+ with self.assertRaises(OSError):
176
+ self.deliver()
177
+ [status] = auditstream.status()
178
+ self.assertEqual((status['delivered_seq'], status['behind']), (2, 3))
179
+ finally:
180
+ _docker('start', container)
181
+ deadline = time.monotonic() + 60
182
+ while True: # until it listens again
183
+ try:
184
+ delivered = self.deliver()
185
+ break
186
+ except OSError:
187
+ if time.monotonic() > deadline:
188
+ raise
189
+ time.sleep(1)
190
+ self.assertEqual(delivered, 3)
191
+ rows = self.received(5)
192
+ self.assertEqual(sorted({r[3]['seq'] for r in rows}), [1, 2, 3, 4, 5])
193
+ self.assertTrue(auditchain.verify_file(self.copy_as_received(rows))['intact'])
194
+
195
+
196
+ @unittest.skipUnless(SIEM, SKIP)
197
+ class RsyslogTests(SyslogReceiverTests, SiemTestCase):
198
+ receiver = 'rsyslog'
199
+
200
+
201
+ @unittest.skipUnless(SIEM, SKIP)
202
+ class SyslogNgTests(SyslogReceiverTests, SiemTestCase):
203
+ receiver = 'syslog-ng'
204
+
205
+
206
+ @unittest.skipUnless(SIEM, SKIP)
207
+ class OtelCollectorTests(SiemTestCase):
208
+ receiver = 'otelcol'
209
+
210
+ def test_every_entry_arrives_as_a_log_record_with_its_attributes(self):
211
+ self.write(3)
212
+ self.assertEqual(self.deliver(), 3)
213
+ records = self.received(3)
214
+ self.assertEqual([e['seq'] for _a, e in records], [1, 2, 3])
215
+ for attributes, entry in records:
216
+ self.assertEqual(int(attributes['queryapigate.audit.seq']), entry['seq'])
217
+ self.assertEqual(attributes['queryapigate.audit.hash'], entry['hash'])
218
+ self.assertEqual(attributes['queryapigate.audit.action'], 'update_key')
219
+ self.assertEqual(attributes['queryapigate.audit.actor'], 'alice')
220
+ self.assertTrue(auditchain.verify_file(self.copy_as_received(records))['intact'])
221
+
222
+ def test_an_outage_loses_nothing(self):
223
+ container = RECEIVERS['otelcol']['container']
224
+ _docker('stop', container)
225
+ try:
226
+ self.write(2)
227
+ with self.assertRaises(OSError):
228
+ self.deliver()
229
+ self.assertEqual(auditstream.status()[0]['delivered_seq'], 0)
230
+ finally:
231
+ _docker('start', container)
232
+ deadline = time.monotonic() + 60
233
+ while True:
234
+ try:
235
+ delivered = self.deliver()
236
+ break
237
+ except OSError:
238
+ if time.monotonic() > deadline:
239
+ raise
240
+ time.sleep(1)
241
+ self.assertEqual(delivered, 2)
242
+ self.assertEqual([e['seq'] for _a, e in self.received(2)], [1, 2])
243
+
244
+
245
+ if __name__ == '__main__':
246
+ unittest.main()
@@ -65,18 +65,22 @@ class MarkedEverywhereTests(unittest.TestCase):
65
65
  note = read(path)[match.start():match.start() + 900].replace('\n>', ' ')
66
66
  self.assertIn('may still be added', ' '.join(note.split()))
67
67
 
68
- def test_features_graduated_in_0_30_say_what_may_be_added_and_their_limits(self):
69
- """ADR 0017: each section opens with the promise, what may still be added, and its limits."""
68
+ def test_features_graduated_since_0_30_say_what_may_be_added_and_their_limits(self):
69
+ """ADR 0017 and after: each section opens with the promise and when it began, what may still be added, and its
70
+ limits."""
70
71
  api = 'documentation/API.md'
71
- for path, heading in ((api, 'Descriptions'), (api, 'Agent usage'), (api, 'Usage quotas'),
72
- (api, 'Configuration as code'), (api, 'Console sign-in through SSO'),
73
- ('documentation/MCP.md', 'Sign-in for AI clients (OAuth)'),
74
- ('documentation/MCP.md', 'Write tools: agents that write, approved by a person'),
75
- ('documentation/DEPLOYMENT.md', 'Kubernetes (Helm)')):
72
+ for path, heading, since in ((api, 'Descriptions', '0.30'), (api, 'Agent usage', '0.30'),
73
+ (api, 'Usage quotas', '0.30'), (api, 'Configuration as code', '0.30'),
74
+ (api, 'Console sign-in through SSO', '0.30'),
75
+ (api, 'Streaming the audit log to a SIEM', '0.32'),
76
+ ('documentation/MCP.md', 'Sign-in for AI clients (OAuth)', '0.30'),
77
+ ('documentation/MCP.md', 'Write tools: agents that write, approved by a person',
78
+ '0.30'),
79
+ ('documentation/DEPLOYMENT.md', 'Kubernetes (Helm)', '0.30')):
76
80
  with self.subTest(heading=heading):
77
81
  text = read(path)
78
82
  match = re.search(rf'^#+ {re.escape(heading)}\n+(.+)', text, re.M)
79
- self.assertTrue(match.group(1).startswith('> **Stable** since 0.30'), match.group(1))
83
+ self.assertTrue(match.group(1).startswith(f'> **Stable** since {since}'), match.group(1))
80
84
  callout = text[match.start(1):].split('\n\n', 1)[0].replace('\n>', ' ')
81
85
  self.assertIn('may still be added', ' '.join(callout.split()))
82
86
  self.assertIn('Limits:', callout)
@@ -37,6 +37,15 @@ class PinnedTests(unittest.TestCase):
37
37
  with self.subTest(image=image):
38
38
  self.assertRegex(image, r'@sha256:[0-9a-f]{64}$')
39
39
 
40
+ def test_the_siem_receivers_ci_tests_against_are_pinned_by_digest(self):
41
+ """ADR 0019: rsyslog, syslog-ng and the OpenTelemetry Collector, as siem_receivers.sh runs them."""
42
+ with open(os.path.join(ROOT, '.github', 'scripts', 'siem_receivers.sh')) as f:
43
+ images = dict(re.findall(r'^(RSYSLOG|SYSLOG_NG|OTELCOL)=(\S+)$', f.read(), re.M))
44
+ self.assertEqual(set(images), {'RSYSLOG', 'SYSLOG_NG', 'OTELCOL'})
45
+ for name, image in images.items():
46
+ with self.subTest(name=name):
47
+ self.assertRegex(image, r':[\w.-]+@sha256:[0-9a-f]{64}$')
48
+
40
49
  def test_dependabot_proposes_the_updates(self):
41
50
  with open(os.path.join(ROOT, '.github', 'dependabot.yml')) as f:
42
51
  ecosystems = {u['package-ecosystem'] for u in yaml.safe_load(f)['updates']}
File without changes