howler-api 4.1.0.dev1588__tar.gz → 4.1.0.dev1611__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 (239) hide show
  1. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/PKG-INFO +1 -1
  2. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/action.py +13 -2
  3. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/dossier.py +3 -1
  4. howler_api-4.1.0.dev1611/howler/api/v1/utils/permission_helper.py +83 -0
  5. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/view.py +30 -24
  6. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/collection.py +173 -69
  7. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/howler_store.py +6 -0
  8. howler_api-4.1.0.dev1611/howler/datastore/migrations/__init__.py +4 -0
  9. howler_api-4.1.0.dev1611/howler/datastore/migrations/action_owner.py +34 -0
  10. howler_api-4.1.0.dev1611/howler/datastore/migrations/base.py +20 -0
  11. howler_api-4.1.0.dev1611/howler/datastore/migrations/runner.py +313 -0
  12. howler_api-4.1.0.dev1611/howler/external/README.md +84 -0
  13. howler_api-4.1.0.dev1611/howler/external/run_migrations.py +139 -0
  14. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/base.py +6 -3
  15. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/action.py +2 -2
  16. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/dossier.py +3 -2
  17. howler_api-4.1.0.dev1611/howler/odm/models/migration.py +28 -0
  18. howler_api-4.1.0.dev1611/howler/odm/models/ownership.py +17 -0
  19. howler_api-4.1.0.dev1611/howler/odm/models/permission_request.py +17 -0
  20. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/view.py +3 -5
  21. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/random_data.py +28 -11
  22. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/dossier_service.py +7 -10
  23. howler_api-4.1.0.dev1611/howler/services/permission_service.py +150 -0
  24. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/pyproject.toml +2 -1
  25. howler_api-4.1.0.dev1588/howler/external/README.md +0 -30
  26. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/README.md +0 -0
  27. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/__init__.py +0 -0
  28. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/__init__.py +0 -0
  29. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/add_label.py +0 -0
  30. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/add_to_bundle.py +0 -0
  31. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/add_to_case.py +0 -0
  32. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/change_field.py +0 -0
  33. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/demote.py +0 -0
  34. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/example_plugin.py +0 -0
  35. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/prioritization.py +0 -0
  36. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/promote.py +0 -0
  37. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/remove_from_bundle.py +0 -0
  38. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/remove_label.py +0 -0
  39. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/actions/transition.py +0 -0
  40. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/__init__.py +0 -0
  41. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/base.py +0 -0
  42. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/socket.py +0 -0
  43. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/__init__.py +0 -0
  44. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/analytic.py +0 -0
  45. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/auth.py +0 -0
  46. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/clue.py +0 -0
  47. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/configs.py +0 -0
  48. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/help.py +0 -0
  49. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/hit.py +0 -0
  50. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/notebook.py +0 -0
  51. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/overview.py +0 -0
  52. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/search.py +0 -0
  53. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/template.py +0 -0
  54. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/tool.py +0 -0
  55. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/user.py +0 -0
  56. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/utils/__init__.py +0 -0
  57. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/utils/etag.py +0 -0
  58. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v1/utils/params.py +0 -0
  59. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v2/__init__.py +0 -0
  60. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v2/case.py +0 -0
  61. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v2/fuzzy.py +0 -0
  62. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v2/ingest.py +0 -0
  63. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/api/v2/search.py +0 -0
  64. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/app.py +0 -0
  65. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/README.md +0 -0
  66. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/__init__.py +0 -0
  67. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/classification.py +0 -0
  68. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/classification.yml +0 -0
  69. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/exceptions.py +0 -0
  70. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/loader.py +0 -0
  71. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/logging/__init__.py +0 -0
  72. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/logging/audit.py +0 -0
  73. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/logging/format.py +0 -0
  74. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/net.py +0 -0
  75. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/net_static.py +0 -0
  76. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/random_user.py +0 -0
  77. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/common/swagger.py +0 -0
  78. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/config.py +0 -0
  79. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/cronjobs/__init__.py +0 -0
  80. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/cronjobs/action_queue_worker.py +0 -0
  81. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/cronjobs/correlation.py +0 -0
  82. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/cronjobs/retention.py +0 -0
  83. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/cronjobs/view_cleanup.py +0 -0
  84. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/README.md +0 -0
  85. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/__init__.py +0 -0
  86. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/bulk.py +0 -0
  87. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/constants.py +0 -0
  88. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/exceptions.py +0 -0
  89. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/migrations/fix_process.py +0 -0
  90. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/operations.py +0 -0
  91. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/schemas.py +0 -0
  92. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/store.py +0 -0
  93. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/support/__init__.py +0 -0
  94. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/support/build.py +0 -0
  95. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/support/schemas.py +0 -0
  96. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/types.py +0 -0
  97. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/datastore/utils.py +0 -0
  98. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/error.py +0 -0
  99. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/__init__.py +0 -0
  100. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/check_duplicates.py +0 -0
  101. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/generate_mitre.py +0 -0
  102. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/generate_sigma_rules.py +0 -0
  103. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/generate_tlds.py +0 -0
  104. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/reindex_data.py +0 -0
  105. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/external/wipe_databases.py +0 -0
  106. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/gunicorn_config.py +0 -0
  107. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/healthz.py +0 -0
  108. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/__init__.py +0 -0
  109. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/azure.py +0 -0
  110. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/discover.py +0 -0
  111. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/hit.py +0 -0
  112. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/oauth.py +0 -0
  113. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/search.py +0 -0
  114. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/workflow.py +0 -0
  115. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/helper/ws.py +0 -0
  116. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/README.md +0 -0
  117. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/__init__.py +0 -0
  118. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/charter.txt +0 -0
  119. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/constants.py +0 -0
  120. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/helper.py +0 -0
  121. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/howler_enum.py +0 -0
  122. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/mixins.py +0 -0
  123. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/__init__.py +0 -0
  124. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/analytic.py +0 -0
  125. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/assemblyline.py +0 -0
  126. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/aws.py +0 -0
  127. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/azure.py +0 -0
  128. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/case.py +0 -0
  129. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/cbs.py +0 -0
  130. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/clue.py +0 -0
  131. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/config.py +0 -0
  132. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/__init__.py +0 -0
  133. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/agent.py +0 -0
  134. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/autonomous_system.py +0 -0
  135. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/client.py +0 -0
  136. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/cloud.py +0 -0
  137. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/code_signature.py +0 -0
  138. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/container.py +0 -0
  139. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/dns.py +0 -0
  140. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/egress.py +0 -0
  141. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/elf.py +0 -0
  142. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/email.py +0 -0
  143. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/error.py +0 -0
  144. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/event.py +0 -0
  145. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/faas.py +0 -0
  146. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/file.py +0 -0
  147. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/geo.py +0 -0
  148. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/group.py +0 -0
  149. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/hash.py +0 -0
  150. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/host.py +0 -0
  151. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/http.py +0 -0
  152. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/ingress.py +0 -0
  153. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/interface.py +0 -0
  154. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/network.py +0 -0
  155. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/observer.py +0 -0
  156. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/organization.py +0 -0
  157. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/os.py +0 -0
  158. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/pe.py +0 -0
  159. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/process.py +0 -0
  160. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/registry.py +0 -0
  161. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/related.py +0 -0
  162. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/rule.py +0 -0
  163. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/server.py +0 -0
  164. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/threat.py +0 -0
  165. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/tls.py +0 -0
  166. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/url.py +0 -0
  167. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/user.py +0 -0
  168. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/user_agent.py +0 -0
  169. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/ecs/vulnerability.py +0 -0
  170. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/event.py +0 -0
  171. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/gcp.py +0 -0
  172. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/hit.py +0 -0
  173. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/howler_data.py +0 -0
  174. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/lead.py +0 -0
  175. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/localized_label.py +0 -0
  176. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/overview.py +0 -0
  177. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/pivot.py +0 -0
  178. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/record.py +0 -0
  179. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/template.py +0 -0
  180. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/models/user.py +0 -0
  181. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/odm/randomizer.py +0 -0
  182. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/patched.py +0 -0
  183. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/plugins/__init__.py +0 -0
  184. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/plugins/config.py +0 -0
  185. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/__init__.py +0 -0
  186. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/README.md +0 -0
  187. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/__init__.py +0 -0
  188. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/counters.py +0 -0
  189. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/events.py +0 -0
  190. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/hash.py +0 -0
  191. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/lock.py +0 -0
  192. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/queues/__init__.py +0 -0
  193. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/queues/comms.py +0 -0
  194. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/queues/multi.py +0 -0
  195. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/queues/named.py +0 -0
  196. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/queues/priority.py +0 -0
  197. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/set.py +0 -0
  198. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/remote/datatypes/user_quota_tracker.py +0 -0
  199. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/security/__init__.py +0 -0
  200. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/security/login.py +0 -0
  201. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/security/socket.py +0 -0
  202. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/security/utils.py +0 -0
  203. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/__init__.py +0 -0
  204. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/action_service.py +0 -0
  205. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/analytic_service.py +0 -0
  206. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/auth_service.py +0 -0
  207. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/bundle_compat_service.py +0 -0
  208. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/case_service.py +0 -0
  209. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/comms_service.py +0 -0
  210. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/config_service.py +0 -0
  211. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/correlation_service.py +0 -0
  212. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/docs_service.py +0 -0
  213. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/event_service.py +0 -0
  214. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/fuzzy_service.py +0 -0
  215. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/hit_service.py +0 -0
  216. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/jwt_service.py +0 -0
  217. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/lucene_service.py +0 -0
  218. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/notebook_service.py +0 -0
  219. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/overview_service.py +0 -0
  220. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/search_service.py +0 -0
  221. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/template_service.py +0 -0
  222. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/user_service.py +0 -0
  223. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/services/viewer_service.py +0 -0
  224. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/telemetry.py +0 -0
  225. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/__init__.py +0 -0
  226. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/annotations.py +0 -0
  227. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/chunk.py +0 -0
  228. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/compat.py +0 -0
  229. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/constants.py +0 -0
  230. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/dict_utils.py +0 -0
  231. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/indexes.py +0 -0
  232. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/isotime.py +0 -0
  233. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/list_utils.py +0 -0
  234. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/lucene.py +0 -0
  235. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/net_utils.py +0 -0
  236. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/path.py +0 -0
  237. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/socket_utils.py +0 -0
  238. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/str_utils.py +0 -0
  239. {howler_api-4.1.0.dev1588 → howler_api-4.1.0.dev1611}/howler/utils/uid.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: howler-api
3
- Version: 4.1.0.dev1588
3
+ Version: 4.1.0.dev1611
4
4
  Summary: Howler - API server
5
5
  License: MIT
6
6
  Keywords: howler,alerting,gc,canada,cse-cst,cse,cst,cyber,cccs
@@ -4,6 +4,7 @@ from flask import Response, request
4
4
 
5
5
  import howler.actions as actions
6
6
  from howler.api import bad_request, created, forbidden, internal_error, make_subapi_blueprint, no_content, not_found, ok
7
+ from howler.api.v1.utils import permission_helper
7
8
  from howler.api.v1.utils.etag import add_etag
8
9
  from howler.api.v1.utils.params import parse_parameters, parse_refresh
9
10
  from howler.common.exceptions import HowlerException
@@ -21,6 +22,7 @@ classification_definition = CLASSIFICATION.get_parsed_classification_definition(
21
22
 
22
23
  action_api = make_subapi_blueprint(SUB_API, api_version=1)
23
24
  action_api._doc = "Endpoints relating to bulk actions and automation" # type: ignore
25
+ permission_helper.add_access_control_endpoints(action_api, Action)
24
26
 
25
27
 
26
28
  @generate_swagger_docs()
@@ -117,7 +119,7 @@ def add_action(user: User, **kwargs) -> Response:
117
119
  return error
118
120
 
119
121
  try:
120
- new_action["owner_id"] = user.uname
122
+ new_action["owner"] = user.uname
121
123
 
122
124
  action_obj = Action(new_action)
123
125
 
@@ -168,6 +170,9 @@ def update_action(id: str, user: User, **kwargs) -> Response:
168
170
  if not isinstance(updated_action, dict):
169
171
  return bad_request(err="Incorrect data structure!")
170
172
 
173
+ if set(updated_action) & {"owner", "admins", "members"}:
174
+ return bad_request(err="You cannot change permissions using this endpoint.")
175
+
171
176
  refresh = kwargs.get("refresh")
172
177
 
173
178
  ds = datastore()
@@ -182,6 +187,12 @@ def update_action(id: str, user: User, **kwargs) -> Response:
182
187
  ):
183
188
  return forbidden(err="Updating triggers requires the role 'automation_advanced'.")
184
189
 
190
+ is_action_admin = user.uname == existing_action.get("owner") or user.uname in existing_action.get("admins", [])
191
+ if not is_action_admin and "admin" not in user.type:
192
+ return forbidden(
193
+ err="You cannot update an action that is not owned by you, or you are not an administrator of."
194
+ )
195
+
185
196
  updated_action = {
186
197
  **existing_action,
187
198
  **updated_action,
@@ -230,7 +241,7 @@ def delete_action(id: str, user: User, **kwargs) -> Response:
230
241
 
231
242
  action: Action = result["items"][0]
232
243
 
233
- if action.owner_id != user.uname and "admin" not in user.type:
244
+ if action.owner != user.uname and "admin" not in user.type:
234
245
  return forbidden(err="You do not have the permissions necessary to delete this action.")
235
246
 
236
247
  try:
@@ -1,6 +1,7 @@
1
1
  from flask import request
2
2
 
3
3
  from howler.api import bad_request, created, forbidden, internal_error, make_subapi_blueprint, no_content, not_found, ok
4
+ from howler.api.v1.utils import permission_helper
4
5
  from howler.api.v1.utils.params import parse_parameters, parse_refresh
5
6
  from howler.common.exceptions import (
6
7
  ForbiddenException,
@@ -19,6 +20,7 @@ from howler.services import dossier_service
19
20
  SUB_API = "dossier"
20
21
  dossier_api = make_subapi_blueprint(SUB_API, api_version=1)
21
22
  dossier_api._doc = "Manage the different dossiers created for filtering hits" # type: ignore
23
+ permission_helper.add_access_control_endpoints(dossier_api, Dossier)
22
24
 
23
25
  logger = get_logger(__file__)
24
26
 
@@ -185,7 +187,7 @@ def delete_dossier(id: str, user: User, **kwargs):
185
187
  return not_found(err="This dossier does not exist")
186
188
 
187
189
  if existing_dossier.owner != user.uname and "admin" not in user.type:
188
- return forbidden(err="You cannot delete a dossier unless you are an administrator, or the owner.")
190
+ return forbidden(err="You cannot delete a dossier unless you are a global administrator or the owner.")
189
191
 
190
192
  success = storage.dossier.delete(id, refresh=refresh)
191
193
 
@@ -0,0 +1,83 @@
1
+ from flask import Blueprint, Response
2
+
3
+ from howler.api import bad_request, conflict, forbidden, ok
4
+ from howler.api.v1.utils.params import parse_parameters, parse_refresh
5
+ from howler.common.exceptions import ForbiddenException, InvalidDataException
6
+ from howler.common.swagger import generate_swagger_docs
7
+ from howler.datastore.exceptions import VersionConflictException
8
+ from howler.odm.models.ownership import Ownership
9
+ from howler.odm.models.user import User
10
+ from howler.security.login import api_login
11
+ from howler.services import permission_service
12
+
13
+
14
+ def add_access_control_endpoints(api: Blueprint, odm: type[Ownership]):
15
+ """Add access control routes for managing user permissions."""
16
+
17
+ @generate_swagger_docs()
18
+ @api.route("/<id>/permission", methods=["PUT"])
19
+ @api_login(required_priv=["R", "W"])
20
+ @parse_parameters(refresh=parse_refresh)
21
+ def give_privilege(id: str, user: User, **kwargs) -> Response:
22
+ """Grant a permission on an action, dossier, or view.
23
+
24
+ Variables:
25
+ id => The identifier of the object whose permissions should be updated.
26
+
27
+ Arguments:
28
+ None
29
+
30
+ Optional Arguments:
31
+ refresh => ('true' | 'false' | 'wait_for') Whether to refresh the datastore before returning.
32
+
33
+ Data Block:
34
+ {
35
+ "privilege": "members", # The permission level: 'owner', 'admins', or 'members'
36
+ "user_ids": ["username"] # The users receiving the permission
37
+ }
38
+
39
+ Result Example:
40
+ {
41
+ ...object # The updated action, dossier, or view
42
+ }
43
+ """
44
+ try:
45
+ result = permission_service.give_privilege(id, user, odm, refresh=kwargs.get("refresh"))
46
+ except VersionConflictException:
47
+ return conflict(err="Someone has updated this item. Please try again")
48
+ except ForbiddenException as e:
49
+ return forbidden(err=e.message)
50
+ except (ValueError, InvalidDataException) as e:
51
+ return bad_request(err=str(e))
52
+ return ok(result)
53
+
54
+ @generate_swagger_docs()
55
+ @api.route("/<id>/permission", methods=["DELETE"])
56
+ @api_login(required_priv=["R", "W"])
57
+ @parse_parameters(refresh=parse_refresh)
58
+ def remove_privilege(id: str, user: User, **kwargs) -> Response:
59
+ """Revoke a permission on an action, dossier, or view.
60
+
61
+ Variables:
62
+ id => The identifier of the object whose permissions should be updated.
63
+
64
+ Arguments:
65
+ None
66
+
67
+ Optional Arguments:
68
+ refresh => ('true' | 'false' | 'wait_for') Whether to refresh the datastore before returning.
69
+
70
+ Result Example:
71
+ {
72
+ ...object # The updated action, dossier, or view
73
+ }
74
+ """
75
+ try:
76
+ result = permission_service.remove_privilege(id, user, odm, refresh=kwargs.get("refresh"))
77
+ except VersionConflictException:
78
+ return conflict(err="Someone has updated this item. Please try again")
79
+ except ForbiddenException as e:
80
+ return forbidden(err=e.message)
81
+ except (ValueError, InvalidDataException) as e:
82
+ return bad_request(err=str(e))
83
+ return ok(result)
@@ -4,6 +4,7 @@ from flask import request
4
4
  from mergedeep.mergedeep import merge
5
5
 
6
6
  from howler.api import bad_request, created, forbidden, make_subapi_blueprint, no_content, not_found, ok
7
+ from howler.api.v1.utils import permission_helper
7
8
  from howler.api.v1.utils.params import parse_parameters, parse_refresh
8
9
  from howler.common.exceptions import HowlerException
9
10
  from howler.common.loader import datastore
@@ -17,6 +18,7 @@ from howler.security.login import api_login
17
18
  SUB_API = "view"
18
19
  view_api = make_subapi_blueprint(SUB_API, api_version=1)
19
20
  view_api._doc = "Manage the different views created for filtering hits" # type: ignore
21
+ permission_helper.add_access_control_endpoints(view_api, View)
20
22
 
21
23
  logger = get_logger(__file__)
22
24
 
@@ -41,7 +43,10 @@ def get_views(user: User, **kwargs):
41
43
  try:
42
44
  return ok(
43
45
  datastore().view.search(
44
- f"type:global OR owner:({user['uname']} OR none)", as_obj=False, rows=1000, sort="title asc"
46
+ f"type:global OR owner:({user['uname']} OR none) OR admins:{user['uname']} OR members:{user['uname']}",
47
+ as_obj=False,
48
+ rows=1000,
49
+ sort="title asc",
45
50
  )["items"]
46
51
  )
47
52
  except ValueError as e:
@@ -52,7 +57,7 @@ def get_views(user: User, **kwargs):
52
57
  @view_api.route("/", methods=["POST"])
53
58
  @api_login(required_priv=["R", "W"])
54
59
  @parse_parameters(refresh=parse_refresh)
55
- def create_view(**kwargs):
60
+ def create_view(user: User, **kwargs):
56
61
  """Create a new view
57
62
 
58
63
  Variables:
@@ -95,12 +100,12 @@ def create_view(**kwargs):
95
100
  # Make sure the query is valid
96
101
  storage.hit.search(view_data["query"])
97
102
 
98
- view = View(view_data)
103
+ view_data["owner"] = user.uname
99
104
 
100
- view.owner = kwargs["user"]["uname"]
105
+ view = View(view_data)
101
106
 
102
107
  if view.type == "personal":
103
- current_user = storage.user.get_if_exists(kwargs["user"]["uname"])
108
+ current_user = storage.user.get_if_exists(user.uname)
104
109
 
105
110
  current_user.favourite_views.append(view.view_id)
106
111
 
@@ -145,7 +150,7 @@ def delete_view(view_id: str, user: User, **kwargs):
145
150
  return not_found(err="This view does not exist")
146
151
 
147
152
  if existing_view.owner != user.uname and "admin" not in user.type:
148
- return forbidden(err="You cannot delete a view unless you are an administrator, or the owner.")
153
+ return forbidden(err="You cannot delete a view unless you are an owner or a global admin.")
149
154
 
150
155
  if existing_view.type == "readonly":
151
156
  return forbidden(err="You cannot delete built-in views.")
@@ -159,7 +164,7 @@ def delete_view(view_id: str, user: User, **kwargs):
159
164
  @view_api.route("/<view_id>", methods=["PUT"])
160
165
  @api_login(required_priv=["R", "W"])
161
166
  @parse_parameters(refresh=parse_refresh)
162
- def update_view(view_id: str, user: User, **kwargs):
167
+ def update_view(view_id: str, user: User, **kwargs): # noqa: C901
163
168
  """Update a view
164
169
 
165
170
  Variables:
@@ -188,8 +193,11 @@ def update_view(view_id: str, user: User, **kwargs):
188
193
  if not isinstance(new_data, dict):
189
194
  return bad_request(err="Invalid data format")
190
195
 
191
- if set(new_data.keys()) & {"view_id", "owner"}:
192
- return bad_request(err="You cannot change the owner or id of a view.")
196
+ if "view_id" in new_data:
197
+ return bad_request(err="You cannot change the view ID.")
198
+
199
+ if set(new_data) & {"owner", "admins", "members"}:
200
+ return bad_request(err="You cannot change permissions using this endpoint.")
193
201
 
194
202
  existing_view: View = storage.view.get_if_exists(view_id)
195
203
  if not existing_view:
@@ -198,27 +206,23 @@ def update_view(view_id: str, user: User, **kwargs):
198
206
  if existing_view.type == "readonly":
199
207
  return forbidden(err="You cannot edit a built-in view.")
200
208
 
201
- if existing_view.type == "personal" and existing_view.owner != user.uname:
202
- return forbidden(err="You cannot update a personal view that is not owned by you.")
203
-
204
- if existing_view.type == "global" and existing_view.owner != user.uname and "admin" not in user.type:
205
- return forbidden(err="Only the owner of a view and administrators can edit a global view.")
206
-
207
- new_view = View(cast(dict, merge({}, existing_view.as_primitives(), new_data)))
208
-
209
- storage.view.save(new_view.view_id, new_view, refresh=refresh)
209
+ is_view_admin = user.uname == existing_view.owner or user.uname in existing_view.admins
210
+ if not is_view_admin and "admin" not in user.type:
211
+ return forbidden(err="You cannot update a view that is not owned by you, or you are not an administrator of.")
210
212
 
211
213
  try:
212
214
  if "query" in new_data:
213
- # Make sure the query is valid
214
215
  storage.hit.search(new_data["query"])
215
-
216
- return ok(storage.view.get_if_exists(existing_view.view_id, as_obj=False))
217
216
  except SearchException:
218
217
  return bad_request(err="You must use a valid query when updating a view.")
219
218
  except HowlerException as e:
220
219
  return bad_request(err=str(e))
221
220
 
221
+ new_view = View(cast(dict, merge({}, existing_view.as_primitives(), new_data)))
222
+ storage.view.save(new_view.view_id, new_view, refresh=refresh)
223
+
224
+ return ok(new_view.as_primitives())
225
+
222
226
 
223
227
  @generate_swagger_docs()
224
228
  @view_api.route("/<view_id>/favourite", methods=["POST"])
@@ -246,10 +250,12 @@ def set_as_favourite(view_id: str, **kwargs):
246
250
  if not existing_view:
247
251
  return not_found(err="This view does not exist")
248
252
 
249
- if existing_view.type != "global" and (
250
- existing_view.owner != kwargs["user"]["uname"] and existing_view.owner != "none"
253
+ if (
254
+ existing_view.type == "personal"
255
+ and kwargs["user"]["uname"] not in [existing_view.owner, *existing_view.admins, *existing_view.members]
256
+ and existing_view.owner != "none"
251
257
  ):
252
- return forbidden(err="You can only favourite global views, or views owned by you.")
258
+ return forbidden(err="You can only favourite global views, or views you have permission to use.")
253
259
 
254
260
  try:
255
261
  current_user = storage.user.get_if_exists(kwargs["user"]["uname"])
@@ -2,11 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import logging
5
+ import math
5
6
  import re
6
7
  import sys
7
8
  import time
8
9
  import typing
9
10
  import warnings
11
+ from collections.abc import Mapping as ABCMapping
10
12
  from copy import deepcopy
11
13
  from datetime import datetime
12
14
  from os import environ
@@ -170,6 +172,9 @@ class ESCollection(Generic[ModelType]):
170
172
  RETRY_NONE = 0
171
173
  RETRY_INFINITY = -1
172
174
  SCROLL_TIMEOUT = "5m"
175
+ TASK_POLL_TIMEOUT = 3600.0
176
+ TASK_POLL_INTERVAL = 0.5
177
+ MAX_VERSION_CONFLICT_ATTEMPTS = 3
173
178
  UPDATE_SET = "SET"
174
179
  UPDATE_INC = "INC"
175
180
  UPDATE_DEC = "DEC"
@@ -458,37 +463,138 @@ class ESCollection(Generic[ModelType]):
458
463
  else:
459
464
  raise
460
465
 
461
- def _get_task_results(self: Self, task):
462
- # This function is only used to wait for a asynchronous task to finish in a graceful manner without
463
- # timing out the elastic client. You can create an async task for long running operation like:
464
- # - update_by_query
465
- # - delete_by_query
466
- # - reindex ...
467
- attempt = 0
468
- res = None
469
- while res is None:
470
- attempt = attempt + 1
471
- try:
472
- res = self.with_retries(
466
+ @staticmethod
467
+ def _task_response_mapping(response, description: str) -> ABCMapping:
468
+ """Return an Elasticsearch response body as a mapping."""
469
+ if isinstance(response, ABCMapping):
470
+ return response
471
+
472
+ body = getattr(response, "body", None)
473
+ if isinstance(body, ABCMapping):
474
+ return body
475
+
476
+ raise DataStoreException(f"Elasticsearch {description} returned a malformed response.")
477
+
478
+ @staticmethod
479
+ def _task_duration(name: str, default: float) -> float:
480
+ value = environ.get(name, str(default))
481
+ try:
482
+ duration = float(value)
483
+ except ValueError as error:
484
+ raise DataStoreException(f"{name} must be a non-negative number, got {value!r}") from error
485
+ if not math.isfinite(duration) or duration < 0:
486
+ raise DataStoreException(f"{name} must be a non-negative number, got {value!r}")
487
+ return duration
488
+
489
+ def _get_task_results(self: Self, task, strict=True):
490
+ """Wait for an asynchronous Elasticsearch task to return a final response."""
491
+ task_response = self._task_response_mapping(task, "task submission")
492
+ if not isinstance(task_response.get("task"), str) or not task_response["task"]:
493
+ raise DataStoreException("Elasticsearch did not return a valid task identifier.")
494
+
495
+ task_id = task_response["task"]
496
+ poll_timeout = self._task_duration("HWL_DATASTORE_TASK_POLL_TIMEOUT", self.TASK_POLL_TIMEOUT)
497
+ poll_interval = self._task_duration("HWL_DATASTORE_TASK_POLL_INTERVAL", self.TASK_POLL_INTERVAL)
498
+ deadline = time.monotonic() + poll_timeout
499
+ while True:
500
+ if time.monotonic() >= deadline:
501
+ raise HowlerRuntimeError(f"Timed out waiting for Elasticsearch task {task_id} to complete.")
502
+
503
+ result = self._task_response_mapping(
504
+ self.with_retries(
473
505
  self.datastore.client.tasks.get,
474
- task_id=task["task"],
475
- wait_for_completion=True,
476
- timeout="10s",
477
- )
478
- except (elasticsearch.exceptions.TransportError, elasticsearch.exceptions.ApiError) as e:
479
- err_code, msg, _ = e.args
480
- if (err_code == 500 or err_code == "500") and msg in [
481
- "timeout_exception",
482
- "receive_timeout_transport_exception",
483
- ]:
484
- pass
485
- else:
486
- logger.exception("Unexpected error on task check")
487
- raise
506
+ task_id=task_id,
507
+ wait_for_completion=False,
508
+ ),
509
+ f"task {task_id}",
510
+ )
511
+ if "error" in result and result["error"] is not None:
512
+ raise DataStoreException(f"Elasticsearch task {task_id} failed: {result['error']!r}")
513
+ if result.get("completed") is False:
514
+ remaining = deadline - time.monotonic()
515
+ if remaining <= 0:
516
+ raise HowlerRuntimeError(f"Timed out waiting for Elasticsearch task {task_id} to complete.")
517
+ time.sleep(min(poll_interval, remaining))
518
+ continue
519
+ if result.get("completed") is not True:
520
+ raise DataStoreException(f"Elasticsearch task {task_id} did not report a completion state.")
521
+
522
+ response = result.get("response")
523
+ if not isinstance(response, ABCMapping):
524
+ raise DataStoreException(f"Elasticsearch task {task_id} completed without a valid response.")
525
+ response = dict(response)
526
+ if strict:
527
+ if response.get("timed_out") is not False:
528
+ raise DataStoreException(f"Elasticsearch task {task_id} returned a timeout result.")
529
+ failures = response.get("failures")
530
+ if not isinstance(failures, list):
531
+ raise DataStoreException(f"Elasticsearch task {task_id} returned an invalid failures list.")
532
+ if failures:
533
+ raise DataStoreException(f"Elasticsearch task {task_id} reported failures: {failures!r}")
534
+ return response
535
+
536
+ @staticmethod
537
+ def _normalize_query_refresh(refresh):
538
+ if refresh is None or isinstance(refresh, bool):
539
+ return refresh
540
+ if isinstance(refresh, str):
541
+ normalized = refresh.lower()
542
+ if normalized == "true":
543
+ return True
544
+ if normalized == "false":
545
+ return False
546
+ if normalized == "wait_for":
547
+ return True
548
+ raise DataStoreException(f"Invalid refresh value for an Elasticsearch query operation: {refresh!r}")
488
549
 
489
- result = res.get("response", res["task"]["status"])
550
+ @staticmethod
551
+ def _validate_async_query_result(result, count_field: str) -> dict[str, Any]:
552
+ result = ESCollection._task_response_mapping(result, "query task")
553
+ if result.get("error") is not None:
554
+ raise DataStoreException(f"Elasticsearch query task failed: {result['error']!r}")
555
+ if result.get("timed_out") is not False:
556
+ if result.get("timed_out") is True:
557
+ raise DataStoreException("Elasticsearch query task timed out.")
558
+ raise DataStoreException("Elasticsearch query task did not report whether it timed out.")
559
+
560
+ failures = result.get("failures")
561
+ if not isinstance(failures, list):
562
+ raise DataStoreException("Elasticsearch query task did not return a valid failures list.")
563
+ if failures:
564
+ raise DataStoreException(f"Elasticsearch query task reported failures: {failures!r}")
565
+
566
+ validated_result = dict(result)
567
+ for field_name in (count_field, "version_conflicts"):
568
+ value = validated_result.get(field_name)
569
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
570
+ raise DataStoreException(f"Elasticsearch query task returned an invalid {field_name} value.")
571
+
572
+ return validated_result
573
+
574
+ def _run_async_query(self, operation, operation_name: str, count_field: str, **kwargs):
575
+ refresh = self._normalize_query_refresh(kwargs.pop("refresh", None))
576
+ if self.MAX_VERSION_CONFLICT_ATTEMPTS < 1:
577
+ raise DataStoreException("MAX_VERSION_CONFLICT_ATTEMPTS must be at least one.")
578
+
579
+ affected_documents = 0
580
+ for _ in range(self.MAX_VERSION_CONFLICT_ATTEMPTS):
581
+ task = self.with_retries(
582
+ operation,
583
+ **kwargs,
584
+ wait_for_completion=False,
585
+ conflicts="proceed",
586
+ refresh=refresh,
587
+ )
588
+ result = self._validate_async_query_result(self._get_task_results(task, strict=True), count_field)
589
+ affected_documents += result[count_field]
590
+ if result["version_conflicts"] == 0:
591
+ result[count_field] = affected_documents
592
+ return result
490
593
 
491
- return result
594
+ raise DataStoreException(
595
+ f"Elasticsearch {operation_name} did not complete after "
596
+ f"{self.MAX_VERSION_CONFLICT_ATTEMPTS} version-conflict attempt(s)."
597
+ )
492
598
 
493
599
  def _get_current_alias(self: Self, index: str) -> typing.Optional[str]:
494
600
  if self.with_retries(self.datastore.client.indices.exists_alias, name=index):
@@ -524,46 +630,28 @@ class ESCollection(Generic[ModelType]):
524
630
  self._wait_for_status(target, min_status=min_status)
525
631
 
526
632
  def _delete_async(self: Self, index, query, max_docs=None, sort=None, refresh=None):
527
- deleted = 0
528
- while True:
529
- task = self.with_retries(
530
- self.datastore.client.delete_by_query,
531
- index=index,
532
- query=query,
533
- wait_for_completion=False,
534
- conflicts="proceed",
535
- sort=sort,
536
- max_docs=max_docs,
537
- refresh=refresh,
538
- )
539
- res = self._get_task_results(task)
540
-
541
- if res["version_conflicts"] == 0:
542
- res["deleted"] += deleted
543
- return res
544
- else:
545
- deleted += res["deleted"]
633
+ return self._run_async_query(
634
+ self.datastore.client.delete_by_query,
635
+ "delete-by-query",
636
+ "deleted",
637
+ index=index,
638
+ query=query,
639
+ sort=sort,
640
+ max_docs=max_docs,
641
+ refresh=refresh,
642
+ )
546
643
 
547
644
  def _update_async(self: Self, index, script, query, max_docs=None, refresh=None):
548
- updated = 0
549
- while True:
550
- task = self.with_retries(
551
- self.datastore.client.update_by_query,
552
- index=index,
553
- script=script,
554
- query=query,
555
- wait_for_completion=False,
556
- conflicts="proceed",
557
- max_docs=max_docs,
558
- refresh=refresh,
559
- )
560
- res = self._get_task_results(task)
561
-
562
- if res["version_conflicts"] == 0:
563
- res["updated"] += updated
564
- return res
565
- else:
566
- updated += res["updated"]
645
+ return self._run_async_query(
646
+ self.datastore.client.update_by_query,
647
+ "update-by-query",
648
+ "updated",
649
+ index=index,
650
+ script=script,
651
+ query=query,
652
+ max_docs=max_docs,
653
+ refresh=refresh,
654
+ )
567
655
 
568
656
  def bulk(self: Self, operations: ElasticBulkPlan, refresh: str | None = None):
569
657
  """
@@ -859,7 +947,7 @@ class ESCollection(Generic[ModelType]):
859
947
  wait_for_completion=False,
860
948
  )
861
949
  logger.warning("Reindex taskId: %s", r_task["task"])
862
- reindex_result = self._get_task_results(r_task)
950
+ reindex_result = self._get_task_results(r_task, strict=False)
863
951
 
864
952
  # Validate the reindex did not silently drop or conflict on documents before
865
953
  # we commit to deleting the source index further down.
@@ -1447,15 +1535,28 @@ class ESCollection(Generic[ModelType]):
1447
1535
 
1448
1536
  return True
1449
1537
 
1450
- def delete(self: Self, key, refresh=None):
1538
+ def delete(self: Self, key: str, refresh=None, version=None):
1451
1539
  """This function should delete the underlying document referenced by the key.
1452
1540
  It should return true if the document was in fact properly deleted.
1453
1541
 
1454
1542
  :param key: id of the document to delete
1543
+ :param version: version token returned by ``get_if_exists(..., version=True)`` for an atomic delete
1455
1544
  :return: True is delete successful
1456
1545
  """
1546
+ index = self.name
1547
+ kwargs = {"id": key, "index": index, "refresh": refresh}
1548
+ if version:
1549
+ if version == CREATE_TOKEN:
1550
+ raise DataStoreException("Cannot delete a document using the create version token.")
1551
+ index, seq_no, primary_term = self._get_version_write_target(version)
1552
+ kwargs.update({"index": index, "if_seq_no": seq_no, "if_primary_term": primary_term})
1553
+
1457
1554
  try:
1458
- info = self.with_retries(self.datastore.client.delete, id=key, index=self.name, refresh=refresh)
1555
+ info = self.with_retries(
1556
+ self.datastore.client.delete,
1557
+ raise_conflicts=bool(version),
1558
+ **kwargs,
1559
+ )
1459
1560
  return info["result"] == "deleted"
1460
1561
  except elasticsearch.NotFoundError:
1461
1562
  return False
@@ -1802,6 +1903,9 @@ class ESCollection(Generic[ModelType]):
1802
1903
  if isinstance(fields, str):
1803
1904
  fields = fields.split(",")
1804
1905
 
1906
+ if source_data is None:
1907
+ source_data = {}
1908
+
1805
1909
  if source_data is not None and (fields is None or "*" in fields or "id" in fields):
1806
1910
  source_data["id"] = [item_id]
1807
1911
 
@@ -12,6 +12,7 @@ from howler.odm.models.config import ILMIndexConfig
12
12
  from howler.odm.models.dossier import Dossier
13
13
  from howler.odm.models.event import Event
14
14
  from howler.odm.models.hit import Hit
15
+ from howler.odm.models.migration import MigrationRecord
15
16
  from howler.odm.models.overview import Overview
16
17
  from howler.odm.models.template import Template
17
18
  from howler.odm.models.user import User
@@ -29,6 +30,7 @@ INDEXES = {
29
30
  "overview": Overview,
30
31
  "analytic": Analytic,
31
32
  "action": Action,
33
+ "migration": MigrationRecord,
32
34
  "user": User,
33
35
  "view": View,
34
36
  "dossier": Dossier,
@@ -119,6 +121,10 @@ class HowlerDatastore(object):
119
121
  def action(self) -> ESCollection[Action]:
120
122
  return self.ds.action
121
123
 
124
+ @property
125
+ def migration(self) -> ESCollection[MigrationRecord]:
126
+ return self.ds.migration
127
+
122
128
  @property
123
129
  def user(self) -> ESCollection[User]:
124
130
  return self.ds.user
@@ -0,0 +1,4 @@
1
+ from howler.datastore.migrations.base import Migration
2
+ from howler.datastore.migrations.runner import MIGRATIONS, run_migrations
3
+
4
+ __all__ = ["MIGRATIONS", "Migration", "run_migrations"]