howler-api 2.13.0.dev329__py3-none-any.whl

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.

Potentially problematic release.


This version of howler-api might be problematic. Click here for more details.

Files changed (200) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +167 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/borealis.py +101 -0
  21. howler/api/v1/configs.py +55 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +715 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +414 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +144 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/hexdump.py +48 -0
  41. howler/common/iprange.py +171 -0
  42. howler/common/loader.py +154 -0
  43. howler/common/logging/__init__.py +241 -0
  44. howler/common/logging/audit.py +138 -0
  45. howler/common/logging/format.py +38 -0
  46. howler/common/net.py +79 -0
  47. howler/common/net_static.py +1494 -0
  48. howler/common/random_user.py +316 -0
  49. howler/common/swagger.py +117 -0
  50. howler/config.py +64 -0
  51. howler/cronjobs/__init__.py +29 -0
  52. howler/cronjobs/retention.py +61 -0
  53. howler/cronjobs/rules.py +274 -0
  54. howler/cronjobs/view_cleanup.py +88 -0
  55. howler/datastore/README.md +112 -0
  56. howler/datastore/__init__.py +0 -0
  57. howler/datastore/bulk.py +72 -0
  58. howler/datastore/collection.py +2327 -0
  59. howler/datastore/constants.py +117 -0
  60. howler/datastore/exceptions.py +41 -0
  61. howler/datastore/howler_store.py +105 -0
  62. howler/datastore/migrations/fix_process.py +41 -0
  63. howler/datastore/operations.py +130 -0
  64. howler/datastore/schemas.py +90 -0
  65. howler/datastore/store.py +231 -0
  66. howler/datastore/support/__init__.py +0 -0
  67. howler/datastore/support/build.py +214 -0
  68. howler/datastore/support/schemas.py +90 -0
  69. howler/datastore/types.py +22 -0
  70. howler/error.py +91 -0
  71. howler/external/__init__.py +0 -0
  72. howler/external/generate_mitre.py +96 -0
  73. howler/external/generate_sigma_rules.py +31 -0
  74. howler/external/generate_tlds.py +47 -0
  75. howler/external/reindex_data.py +46 -0
  76. howler/external/wipe_databases.py +58 -0
  77. howler/gunicorn_config.py +25 -0
  78. howler/healthz.py +47 -0
  79. howler/helper/__init__.py +0 -0
  80. howler/helper/azure.py +50 -0
  81. howler/helper/discover.py +59 -0
  82. howler/helper/hit.py +236 -0
  83. howler/helper/oauth.py +247 -0
  84. howler/helper/search.py +92 -0
  85. howler/helper/workflow.py +110 -0
  86. howler/helper/ws.py +378 -0
  87. howler/odm/README.md +102 -0
  88. howler/odm/__init__.py +1 -0
  89. howler/odm/base.py +1504 -0
  90. howler/odm/charter.txt +146 -0
  91. howler/odm/helper.py +416 -0
  92. howler/odm/howler_enum.py +25 -0
  93. howler/odm/models/__init__.py +0 -0
  94. howler/odm/models/action.py +33 -0
  95. howler/odm/models/analytic.py +90 -0
  96. howler/odm/models/assemblyline.py +48 -0
  97. howler/odm/models/aws.py +23 -0
  98. howler/odm/models/azure.py +16 -0
  99. howler/odm/models/cbs.py +44 -0
  100. howler/odm/models/config.py +558 -0
  101. howler/odm/models/dossier.py +33 -0
  102. howler/odm/models/ecs/__init__.py +0 -0
  103. howler/odm/models/ecs/agent.py +17 -0
  104. howler/odm/models/ecs/autonomous_system.py +16 -0
  105. howler/odm/models/ecs/client.py +149 -0
  106. howler/odm/models/ecs/cloud.py +141 -0
  107. howler/odm/models/ecs/code_signature.py +27 -0
  108. howler/odm/models/ecs/container.py +32 -0
  109. howler/odm/models/ecs/dns.py +62 -0
  110. howler/odm/models/ecs/egress.py +10 -0
  111. howler/odm/models/ecs/elf.py +74 -0
  112. howler/odm/models/ecs/email.py +122 -0
  113. howler/odm/models/ecs/error.py +14 -0
  114. howler/odm/models/ecs/event.py +140 -0
  115. howler/odm/models/ecs/faas.py +24 -0
  116. howler/odm/models/ecs/file.py +84 -0
  117. howler/odm/models/ecs/geo.py +30 -0
  118. howler/odm/models/ecs/group.py +18 -0
  119. howler/odm/models/ecs/hash.py +16 -0
  120. howler/odm/models/ecs/host.py +17 -0
  121. howler/odm/models/ecs/http.py +37 -0
  122. howler/odm/models/ecs/ingress.py +12 -0
  123. howler/odm/models/ecs/interface.py +21 -0
  124. howler/odm/models/ecs/network.py +30 -0
  125. howler/odm/models/ecs/observer.py +45 -0
  126. howler/odm/models/ecs/organization.py +12 -0
  127. howler/odm/models/ecs/os.py +21 -0
  128. howler/odm/models/ecs/pe.py +17 -0
  129. howler/odm/models/ecs/process.py +216 -0
  130. howler/odm/models/ecs/registry.py +26 -0
  131. howler/odm/models/ecs/related.py +45 -0
  132. howler/odm/models/ecs/rule.py +51 -0
  133. howler/odm/models/ecs/server.py +24 -0
  134. howler/odm/models/ecs/threat.py +247 -0
  135. howler/odm/models/ecs/tls.py +58 -0
  136. howler/odm/models/ecs/url.py +51 -0
  137. howler/odm/models/ecs/user.py +57 -0
  138. howler/odm/models/ecs/user_agent.py +20 -0
  139. howler/odm/models/ecs/vulnerability.py +41 -0
  140. howler/odm/models/gcp.py +16 -0
  141. howler/odm/models/hit.py +356 -0
  142. howler/odm/models/howler_data.py +328 -0
  143. howler/odm/models/lead.py +33 -0
  144. howler/odm/models/localized_label.py +13 -0
  145. howler/odm/models/overview.py +16 -0
  146. howler/odm/models/pivot.py +40 -0
  147. howler/odm/models/template.py +24 -0
  148. howler/odm/models/user.py +83 -0
  149. howler/odm/models/view.py +34 -0
  150. howler/odm/random_data.py +888 -0
  151. howler/odm/randomizer.py +606 -0
  152. howler/patched.py +5 -0
  153. howler/plugins/__init__.py +25 -0
  154. howler/plugins/config.py +123 -0
  155. howler/remote/__init__.py +0 -0
  156. howler/remote/datatypes/README.md +355 -0
  157. howler/remote/datatypes/__init__.py +98 -0
  158. howler/remote/datatypes/counters.py +63 -0
  159. howler/remote/datatypes/events.py +66 -0
  160. howler/remote/datatypes/hash.py +206 -0
  161. howler/remote/datatypes/lock.py +42 -0
  162. howler/remote/datatypes/queues/__init__.py +0 -0
  163. howler/remote/datatypes/queues/comms.py +59 -0
  164. howler/remote/datatypes/queues/multi.py +32 -0
  165. howler/remote/datatypes/queues/named.py +93 -0
  166. howler/remote/datatypes/queues/priority.py +215 -0
  167. howler/remote/datatypes/set.py +118 -0
  168. howler/remote/datatypes/user_quota_tracker.py +54 -0
  169. howler/security/__init__.py +253 -0
  170. howler/security/socket.py +108 -0
  171. howler/security/utils.py +185 -0
  172. howler/services/__init__.py +0 -0
  173. howler/services/action_service.py +111 -0
  174. howler/services/analytic_service.py +128 -0
  175. howler/services/auth_service.py +323 -0
  176. howler/services/config_service.py +128 -0
  177. howler/services/dossier_service.py +252 -0
  178. howler/services/event_service.py +93 -0
  179. howler/services/hit_service.py +893 -0
  180. howler/services/jwt_service.py +158 -0
  181. howler/services/lucene_service.py +286 -0
  182. howler/services/notebook_service.py +119 -0
  183. howler/services/overview_service.py +44 -0
  184. howler/services/template_service.py +45 -0
  185. howler/services/user_service.py +330 -0
  186. howler/utils/__init__.py +0 -0
  187. howler/utils/annotations.py +28 -0
  188. howler/utils/chunk.py +38 -0
  189. howler/utils/dict_utils.py +200 -0
  190. howler/utils/isotime.py +17 -0
  191. howler/utils/list_utils.py +11 -0
  192. howler/utils/lucene.py +77 -0
  193. howler/utils/path.py +27 -0
  194. howler/utils/socket_utils.py +61 -0
  195. howler/utils/str_utils.py +256 -0
  196. howler/utils/uid.py +47 -0
  197. howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
  198. howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
  199. howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
  200. howler_api-2.13.0.dev329.dist-info/entry_points.txt +8 -0
howler/api/v1/user.py ADDED
@@ -0,0 +1,414 @@
1
+ import re
2
+ from hashlib import sha256
3
+ from typing import Any, Optional
4
+
5
+ from flask import request
6
+
7
+ import howler.services.user_service as user_service
8
+ from howler.api import bad_request, forbidden, internal_error, make_subapi_blueprint, no_content, not_found, ok
9
+ from howler.api.v1.utils.etag import add_etag
10
+ from howler.common.exceptions import (
11
+ AccessDeniedException,
12
+ AuthenticationException,
13
+ HowlerException,
14
+ HowlerValueError,
15
+ InvalidDataException,
16
+ )
17
+ from howler.common.loader import datastore
18
+ from howler.common.logging import get_logger
19
+ from howler.common.swagger import generate_swagger_docs
20
+ from howler.config import config
21
+ from howler.helper.oauth import fetch_groups
22
+ from howler.odm.models.user import User
23
+ from howler.security import api_login
24
+ from howler.security.utils import check_password_requirements, get_password_hash, get_password_requirement_message
25
+
26
+ SUB_API = "user"
27
+ user_api = make_subapi_blueprint(SUB_API, api_version=1)
28
+ user_api._doc = "Manage the different users of the system"
29
+
30
+ logger = get_logger(__file__)
31
+
32
+
33
+ @generate_swagger_docs()
34
+ @user_api.route("/whoami", methods=["GET"])
35
+ @api_login(required_priv=["R"], enforce_quota=False)
36
+ def who_am_i(**kwargs):
37
+ """Return the currently logged in user as well as the system configuration
38
+
39
+ Variables:
40
+ None
41
+
42
+ Arguments:
43
+ None
44
+
45
+ Result Example:
46
+ {
47
+ "avatar": "data:image/jpg...", # Avatar data block
48
+ "classification": "TLP:W", # Classification of the user
49
+ "configuration": { # Configuration block
50
+ "auth": { # Authentication Configuration
51
+ "allow_apikeys": True, # Are APIKeys allowed for the user
52
+ "allow_extended_apikeys": True, # Allow user to generate extended access API Keys
53
+ },
54
+ "system": { # System Configuration
55
+ "type": "production", # Type of deployment
56
+ "version": "4.1" # Howler version
57
+ },
58
+ "ui": { # UI Configuration
59
+ "apps": [], # List of apps shown in the apps switcher
60
+ "banner": None, # Banner displayed on the submit page
61
+ "banner_level": True, # Banner color (info, success, warning, error)
62
+ }
63
+ },
64
+ "email": "basic.user@assemblyline.local", # Email of the user
65
+ "groups": ["USERS"], # Groups the user if member of
66
+ "is_active": True, # Is the user active
67
+ "name": "Basic user", # Name of the user
68
+ "type": ["user", "admin"], # Roles the user is member of
69
+ "username": "sgaron-cyber" # Username of the current user
70
+ }
71
+
72
+ """
73
+ return ok(user_service.convert_user(kwargs["user"]))
74
+
75
+
76
+ @generate_swagger_docs()
77
+ @user_api.route("/<username>", methods=["POST"])
78
+ @api_login(required_type=["admin"])
79
+ def add_user_account(username, **_):
80
+ """Add a user to the system
81
+
82
+ Variables:
83
+ username => Name of the user to add
84
+
85
+ Arguments:
86
+ None
87
+
88
+ Data Block:
89
+ {
90
+ "name": "Test user", # Name of the user
91
+ "is_active": true, # Is the user active?
92
+ "classification": "", # Max classification for user
93
+ "uname": "usertest", # Username
94
+ "type": ['user'], # List of all types the user is member of
95
+ "avatar": null, # Avatar of the user
96
+ "groups": ["TEST"] # Groups the user is member of
97
+ }
98
+
99
+ Result Example:
100
+ {
101
+ "success": true # Saving the user info succeded
102
+ }
103
+ """
104
+ data = request.json
105
+ if not isinstance(data, dict):
106
+ return bad_request(err="Invalid data format")
107
+
108
+ if "{" in username or "}" in username:
109
+ return bad_request(err="You can't use '{}' in the username")
110
+
111
+ storage = datastore()
112
+ if storage.user.get_if_exists(username):
113
+ return bad_request(err="The username you are trying to add already exists.")
114
+
115
+ new_pass = data.pop("new_pass", None)
116
+ if new_pass:
117
+ password_requirements = config.auth.internal.password_requirements.model_dump()
118
+ if not check_password_requirements(new_pass, **password_requirements):
119
+ error_msg = get_password_requirement_message(**password_requirements)
120
+ return bad_request(err=error_msg)
121
+ data["password"] = get_password_hash(new_pass)
122
+ else:
123
+ data["password"] = data.get("password", "__NO_PASSWORD__") or "__NO_PASSWORD__"
124
+
125
+ # Data's username has to match the API call username
126
+ data["uname"] = username
127
+ if not data["name"]:
128
+ data["name"] = data["uname"]
129
+
130
+ # Add dynamic classification group
131
+ data["classification"] = user_service.get_dynamic_classification(data.get("classification", None), data["email"])
132
+
133
+ # Clear non user account data
134
+ avatar = data.pop("avatar", None)
135
+
136
+ if avatar is not None:
137
+ storage.user_avatar.save(username, avatar)
138
+
139
+ try:
140
+ return ok({"success": storage.user.save(username, User(data))})
141
+ except ValueError as e:
142
+ return bad_request(err=str(e))
143
+
144
+
145
+ @generate_swagger_docs()
146
+ @user_api.route("/<username>", methods=["GET"])
147
+ @api_login(audit=False, required_priv=["R"])
148
+ @add_etag(getter=user_service.get_user, check_if_match=True)
149
+ def get_user_account(username: str, server_version: Optional[str] = None, **kwargs):
150
+ """Load the user account information.
151
+
152
+ Variables:
153
+ username => Name of the user to get the account info
154
+
155
+ Arguments:
156
+ load_avatar => If exists, this will load the avatar as well
157
+
158
+ Result Example:
159
+ {
160
+ "name": "Test user", # Name of the user
161
+ "is_active": true, # Is the user active?
162
+ "classification": "", # Max classification for user
163
+ "uname": "usertest", # Username
164
+ "type": ['user'], # List of all types the user is member of
165
+ "avatar": null, # Avatar of the user
166
+ "groups": ["TEST"] # Groups the user is member of
167
+ }
168
+ """
169
+ if username != kwargs["user"]["uname"] and "admin" not in kwargs["user"]["type"]:
170
+ return forbidden(err="You are not allow to view other users then yourself.")
171
+
172
+ user: Optional[User] = kwargs.get("cached_user")
173
+ if not user:
174
+ return not_found(err=f"User {username} does not exist")
175
+
176
+ user: dict[str, Any] = user.as_primitives()
177
+ user["apikeys"] = [(k, []) for k in user.get("apikeys", {}).keys()]
178
+ user["has_password"] = user.pop("password", "") != ""
179
+ user["roles"] = user.pop("type", [])
180
+ user["username"] = user["uname"]
181
+
182
+ if "load_avatar" in request.args:
183
+ user["avatar"] = datastore().user_avatar.get(username)
184
+
185
+ return ok(user), server_version
186
+
187
+
188
+ @generate_swagger_docs()
189
+ @user_api.route("/<username>", methods=["DELETE"])
190
+ @api_login(required_type=["admin"])
191
+ def remove_user_account(username, **_):
192
+ """Remove the account specified by the username.
193
+
194
+ Variables:
195
+ username => Name of the user to get the account info
196
+
197
+ Arguments:
198
+ None
199
+
200
+ Result Example:
201
+ {
202
+ "success": true # Was the remove successful?
203
+ }
204
+ """
205
+ storage = datastore()
206
+ user_data = storage.user.get(username)
207
+ if user_data:
208
+ user_deleted = storage.user.delete(username)
209
+
210
+ if storage.user_avatar.exists(username):
211
+ avatar_deleted = storage.user_avatar.delete(username)
212
+ else:
213
+ avatar_deleted = True
214
+
215
+ if not user_deleted or not avatar_deleted:
216
+ logger.warning("Failed to delete user")
217
+ return internal_error(err="Failed to delete user or avatar. Contact your administrator.")
218
+
219
+ return no_content()
220
+ else:
221
+ return not_found(err=f"User {username} does not exist")
222
+
223
+
224
+ @generate_swagger_docs()
225
+ @user_api.route("/<username>", methods=["PUT"])
226
+ @api_login(required_type=["admin", "user"], enforce_quota=False)
227
+ def set_user_account(username: str, **kwargs): # noqa: C901
228
+ """Save the user account information.
229
+
230
+ Variables:
231
+ username => Name of the user to get the account info
232
+
233
+ Arguments:
234
+ None
235
+
236
+ Data Block:
237
+ {
238
+ "name": "Test user", # Name of the user
239
+ "is_active": true, # Is the user active?
240
+ "classification": "", # Max classification for user
241
+ "uname": "usertest", # Username
242
+ "type": ['user'], # List of all types the user is member of
243
+ "avatar": null, # Avatar of the user
244
+ "groups": ["TEST"] # Groups the user is member of
245
+ }
246
+
247
+ Result Example:
248
+ {
249
+ "success": true # Saving the user info succeded
250
+ }
251
+ """
252
+ try:
253
+ new_data = request.json
254
+ if not isinstance(new_data, dict):
255
+ return bad_request(err="Invalid data format")
256
+
257
+ storage = datastore()
258
+ if not (old_user := storage.user.get_if_exists(username, as_obj=False)):
259
+ return not_found(err=f"User {username} does not exist")
260
+
261
+ data = {**old_user, **new_data}
262
+ new_pass = data.pop("new_pass", None)
263
+
264
+ # Don't allow the overwriting of api keys
265
+ data["apikeys"] = old_user.get("apikeys", [])
266
+
267
+ # Don't allow overwriting of api quota unless you're an admin
268
+ if "admin" not in kwargs["user"]["type"]:
269
+ data["api_quota"] = old_user["api_quota"]
270
+
271
+ if not data["name"]:
272
+ return bad_request(err="Full name of the user cannot be empty")
273
+
274
+ if data["email"] != old_user["email"]:
275
+ return bad_request(err="Cannot update user's email")
276
+
277
+ if data["uname"] != old_user["uname"]:
278
+ return bad_request(err="Cannot update user's username")
279
+
280
+ password_requirements = config.auth.internal.password_requirements.model_dump()
281
+ if not new_pass:
282
+ data["password"] = old_user.get("password", "__NO_PASSWORD__") or "__NO_PASSWORD__"
283
+ elif not check_password_requirements(new_pass, **password_requirements):
284
+ error_msg = get_password_requirement_message(**password_requirements)
285
+ return bad_request(err=error_msg)
286
+ else:
287
+ data["password"] = get_password_hash(new_pass)
288
+ data.pop("new_pass_confirm", None)
289
+
290
+ # Apply dynamic classification
291
+ data["classification"] = user_service.get_dynamic_classification(data["classification"], data["email"])
292
+
293
+ ret_val = user_service.save_user_account(username, data, kwargs["user"])
294
+ return ok({"success": ret_val})
295
+ except AccessDeniedException as e:
296
+ return forbidden(err=str(e))
297
+ except (InvalidDataException, HowlerValueError) as e:
298
+ return bad_request(err=str(e))
299
+
300
+
301
+ ######################################################
302
+ # User's Avatar
303
+ ######################################################
304
+
305
+
306
+ @generate_swagger_docs()
307
+ @user_api.route("/avatar/<username>", methods=["GET"])
308
+ @api_login(audit=True, required_priv=["R"])
309
+ def get_user_avatar(username, **_):
310
+ """Loads the user's avatar.
311
+
312
+ Variables:
313
+ username => Name of the user you want to get the avatar for
314
+
315
+ Arguments:
316
+ None
317
+
318
+ Result Example:
319
+ "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
320
+ """
321
+ storage = datastore()
322
+ avatar: str = storage.user_avatar.get(username)
323
+
324
+ if avatar:
325
+ resp = ok(avatar)
326
+ resp.headers["Cache-Control"] = "private, max-age=3600"
327
+ resp.headers["ETag"] = sha256(avatar.encode("utf-8")).hexdigest()
328
+ return resp
329
+ else:
330
+ return no_content()
331
+
332
+
333
+ @generate_swagger_docs()
334
+ @user_api.route("/avatar/<username>", methods=["POST"])
335
+ @api_login(audit=True)
336
+ def set_user_avatar(username, **kwargs):
337
+ """Sets the user's Avatar
338
+
339
+ Variables:
340
+ username => Name of the user you want to set the avatar for
341
+
342
+ Arguments:
343
+ None
344
+
345
+ Data Block:
346
+ "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
347
+
348
+ Result Example:
349
+ {
350
+ "success": true # Was saving the avatar successful ?
351
+ }
352
+ """
353
+ if username != kwargs["user"]["uname"]:
354
+ return forbidden(err="Cannot save the avatar of another user.")
355
+
356
+ data = request.data
357
+ storage = datastore()
358
+ if data:
359
+ data: str = data.decode("utf-8")
360
+ if not isinstance(data, str) or not storage.user_avatar.save(username, data):
361
+ bad_request(
362
+ err="Data block should be a base64 encoded image that starts with 'data:image/<format>;base64,'"
363
+ )
364
+ else:
365
+ storage.user_avatar.delete(username)
366
+
367
+ return ok()
368
+
369
+
370
+ @generate_swagger_docs()
371
+ @user_api.route("/groups", methods=["GET"])
372
+ @api_login(audit=False)
373
+ def get_user_groups(**kwargs):
374
+ """Gets the user's groups from an oauth provider
375
+
376
+ Variables:
377
+ None
378
+
379
+ Arguments:
380
+ None
381
+
382
+ Result Example:
383
+ [
384
+ {
385
+ "name": "Group Name",
386
+ "id": "abc-123"
387
+ },
388
+ ...
389
+ ]
390
+ """
391
+ auth_header = request.headers.get("Authorization", None)
392
+
393
+ if not auth_header:
394
+ raise AuthenticationException("No Authorization header present")
395
+
396
+ type, token = auth_header.split(" ")
397
+
398
+ group_data = None
399
+ if type == "Bearer" and "." in token:
400
+ try:
401
+ group_data = fetch_groups(token)
402
+ except HowlerException as e:
403
+ return internal_error(e.message)
404
+
405
+ if group_data is None:
406
+ group_data = []
407
+ for g in kwargs["user"].get("groups", []):
408
+ name = re.sub(r"^\w", lambda m: m.group(0).upper(), g)
409
+ name = re.sub(r"[-_]", " ", name)
410
+ name = re.sub(r" \w", lambda m: m.group(0).upper(), name)
411
+
412
+ group_data.append({"name": name, "id": g})
413
+
414
+ return ok(group_data)
File without changes
@@ -0,0 +1,84 @@
1
+ """ETag utility module for handling HTTP ETags in Flask responses.
2
+
3
+ ETags (Entity Tags) are HTTP headers used for web cache validation and conditional requests.
4
+ They help optimize performance by allowing clients to cache responses and only fetch
5
+ new data when the resource has actually changed.
6
+ """
7
+
8
+ import functools
9
+ import re
10
+
11
+ from flask import Response, request
12
+
13
+ from howler.api import not_modified
14
+
15
+
16
+ def add_etag(getter, check_if_match=True):
17
+ """Decorator to add ETag handling to a Flask response.
18
+
19
+ This decorator implements HTTP ETag functionality for API endpoints, enabling:
20
+ - Conditional requests using If-Match headers
21
+ - Cache validation to prevent unnecessary data transfers
22
+ - Version tracking for resources
23
+
24
+ Args:
25
+ getter: Function that retrieves the object and its version
26
+ check_if_match (bool): Whether to check If-Match headers for conditional requests
27
+
28
+ Returns:
29
+ Decorated function with ETag support
30
+ """
31
+
32
+ def wrapper(f):
33
+ """Inner wrapper function that applies ETag functionality to the decorated function."""
34
+
35
+ @functools.wraps(f)
36
+ def generate_etag(*args, **kwargs):
37
+ """Generate and handle ETags for the HTTP response."""
38
+ # Retrieve the object and its version using the provided getter function
39
+ # The getter should return (object, version) tuple
40
+ obj, version = getter(
41
+ kwargs.get("id", kwargs.get("username", None)),
42
+ as_odm=True,
43
+ version=True,
44
+ )
45
+
46
+ # Handle conditional requests with If-Match header
47
+ # If the client's version matches the current version and it's a GET request
48
+ # without metadata parameter, return 304 Not Modified to save bandwidth
49
+ if (
50
+ check_if_match
51
+ and "If-Match" in request.headers
52
+ and request.headers["If-Match"] == version
53
+ and request.method == "GET"
54
+ and "metadata" not in request.args
55
+ ):
56
+ return not_modified()
57
+
58
+ # Extract the resource type from the API path and create a cache key
59
+ # e.g., "/api/v1/users/123" becomes "cached_users"
60
+ key = re.sub(r"^\/api\/v\d+\/(\w+)\/.+$", r"cached_\1", request.path)
61
+ kwargs[key] = obj
62
+
63
+ # Call the original function with the cached object and version
64
+ values = f(*args, server_version=version, **kwargs)
65
+
66
+ # Handle different return value formats from the decorated function
67
+ # If there is only one return, it's just the response
68
+ if isinstance(values, Response):
69
+ # Only add ETag header for successful responses (not 409 Conflict or 400 Bad Request)
70
+ if values.status_code != 409 and values.status_code != 400:
71
+ values.headers["ETag"] = version
72
+ return values
73
+
74
+ # If there are two returns, it's the response and the new version
75
+ # This happens when the function modifies the resource and returns an updated version
76
+ else:
77
+ if values[0].status_code != 409 and values[0].status_code != 400:
78
+ # Add the new ETag version to successful responses
79
+ values[0].headers["ETag"] = values[1]
80
+ return values[0]
81
+
82
+ return generate_etag
83
+
84
+ return wrapper