howler-api 3.0.0.dev374__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 (198) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +168 -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/clue.py +99 -0
  21. howler/api/v1/configs.py +58 -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 +788 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +416 -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 +125 -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/loader.py +154 -0
  41. howler/common/logging/__init__.py +241 -0
  42. howler/common/logging/audit.py +138 -0
  43. howler/common/logging/format.py +38 -0
  44. howler/common/net.py +79 -0
  45. howler/common/net_static.py +1494 -0
  46. howler/common/random_user.py +316 -0
  47. howler/common/swagger.py +117 -0
  48. howler/config.py +64 -0
  49. howler/cronjobs/__init__.py +29 -0
  50. howler/cronjobs/retention.py +61 -0
  51. howler/cronjobs/rules.py +274 -0
  52. howler/cronjobs/view_cleanup.py +88 -0
  53. howler/datastore/README.md +112 -0
  54. howler/datastore/__init__.py +0 -0
  55. howler/datastore/bulk.py +72 -0
  56. howler/datastore/collection.py +2342 -0
  57. howler/datastore/constants.py +119 -0
  58. howler/datastore/exceptions.py +41 -0
  59. howler/datastore/howler_store.py +105 -0
  60. howler/datastore/migrations/fix_process.py +41 -0
  61. howler/datastore/operations.py +130 -0
  62. howler/datastore/schemas.py +90 -0
  63. howler/datastore/store.py +231 -0
  64. howler/datastore/support/__init__.py +0 -0
  65. howler/datastore/support/build.py +215 -0
  66. howler/datastore/support/schemas.py +90 -0
  67. howler/datastore/types.py +22 -0
  68. howler/error.py +91 -0
  69. howler/external/__init__.py +0 -0
  70. howler/external/generate_mitre.py +96 -0
  71. howler/external/generate_sigma_rules.py +31 -0
  72. howler/external/generate_tlds.py +47 -0
  73. howler/external/reindex_data.py +66 -0
  74. howler/external/wipe_databases.py +58 -0
  75. howler/gunicorn_config.py +25 -0
  76. howler/healthz.py +47 -0
  77. howler/helper/__init__.py +0 -0
  78. howler/helper/azure.py +50 -0
  79. howler/helper/discover.py +59 -0
  80. howler/helper/hit.py +236 -0
  81. howler/helper/oauth.py +247 -0
  82. howler/helper/search.py +92 -0
  83. howler/helper/workflow.py +110 -0
  84. howler/helper/ws.py +378 -0
  85. howler/odm/README.md +102 -0
  86. howler/odm/__init__.py +1 -0
  87. howler/odm/base.py +1543 -0
  88. howler/odm/charter.txt +146 -0
  89. howler/odm/helper.py +416 -0
  90. howler/odm/howler_enum.py +25 -0
  91. howler/odm/models/__init__.py +0 -0
  92. howler/odm/models/action.py +33 -0
  93. howler/odm/models/analytic.py +90 -0
  94. howler/odm/models/assemblyline.py +48 -0
  95. howler/odm/models/aws.py +23 -0
  96. howler/odm/models/azure.py +16 -0
  97. howler/odm/models/cbs.py +44 -0
  98. howler/odm/models/config.py +558 -0
  99. howler/odm/models/dossier.py +33 -0
  100. howler/odm/models/ecs/__init__.py +0 -0
  101. howler/odm/models/ecs/agent.py +17 -0
  102. howler/odm/models/ecs/autonomous_system.py +16 -0
  103. howler/odm/models/ecs/client.py +149 -0
  104. howler/odm/models/ecs/cloud.py +141 -0
  105. howler/odm/models/ecs/code_signature.py +27 -0
  106. howler/odm/models/ecs/container.py +32 -0
  107. howler/odm/models/ecs/dns.py +62 -0
  108. howler/odm/models/ecs/egress.py +10 -0
  109. howler/odm/models/ecs/elf.py +74 -0
  110. howler/odm/models/ecs/email.py +122 -0
  111. howler/odm/models/ecs/error.py +14 -0
  112. howler/odm/models/ecs/event.py +140 -0
  113. howler/odm/models/ecs/faas.py +24 -0
  114. howler/odm/models/ecs/file.py +84 -0
  115. howler/odm/models/ecs/geo.py +30 -0
  116. howler/odm/models/ecs/group.py +18 -0
  117. howler/odm/models/ecs/hash.py +16 -0
  118. howler/odm/models/ecs/host.py +17 -0
  119. howler/odm/models/ecs/http.py +37 -0
  120. howler/odm/models/ecs/ingress.py +12 -0
  121. howler/odm/models/ecs/interface.py +21 -0
  122. howler/odm/models/ecs/network.py +30 -0
  123. howler/odm/models/ecs/observer.py +45 -0
  124. howler/odm/models/ecs/organization.py +12 -0
  125. howler/odm/models/ecs/os.py +21 -0
  126. howler/odm/models/ecs/pe.py +17 -0
  127. howler/odm/models/ecs/process.py +216 -0
  128. howler/odm/models/ecs/registry.py +26 -0
  129. howler/odm/models/ecs/related.py +45 -0
  130. howler/odm/models/ecs/rule.py +51 -0
  131. howler/odm/models/ecs/server.py +24 -0
  132. howler/odm/models/ecs/threat.py +247 -0
  133. howler/odm/models/ecs/tls.py +58 -0
  134. howler/odm/models/ecs/url.py +51 -0
  135. howler/odm/models/ecs/user.py +57 -0
  136. howler/odm/models/ecs/user_agent.py +20 -0
  137. howler/odm/models/ecs/vulnerability.py +41 -0
  138. howler/odm/models/gcp.py +16 -0
  139. howler/odm/models/hit.py +356 -0
  140. howler/odm/models/howler_data.py +328 -0
  141. howler/odm/models/lead.py +24 -0
  142. howler/odm/models/localized_label.py +13 -0
  143. howler/odm/models/overview.py +16 -0
  144. howler/odm/models/pivot.py +40 -0
  145. howler/odm/models/template.py +24 -0
  146. howler/odm/models/user.py +83 -0
  147. howler/odm/models/view.py +34 -0
  148. howler/odm/random_data.py +888 -0
  149. howler/odm/randomizer.py +609 -0
  150. howler/patched.py +5 -0
  151. howler/plugins/__init__.py +25 -0
  152. howler/plugins/config.py +123 -0
  153. howler/remote/__init__.py +0 -0
  154. howler/remote/datatypes/README.md +355 -0
  155. howler/remote/datatypes/__init__.py +98 -0
  156. howler/remote/datatypes/counters.py +63 -0
  157. howler/remote/datatypes/events.py +66 -0
  158. howler/remote/datatypes/hash.py +206 -0
  159. howler/remote/datatypes/lock.py +42 -0
  160. howler/remote/datatypes/queues/__init__.py +0 -0
  161. howler/remote/datatypes/queues/comms.py +59 -0
  162. howler/remote/datatypes/queues/multi.py +32 -0
  163. howler/remote/datatypes/queues/named.py +93 -0
  164. howler/remote/datatypes/queues/priority.py +215 -0
  165. howler/remote/datatypes/set.py +118 -0
  166. howler/remote/datatypes/user_quota_tracker.py +54 -0
  167. howler/security/__init__.py +253 -0
  168. howler/security/socket.py +108 -0
  169. howler/security/utils.py +185 -0
  170. howler/services/__init__.py +0 -0
  171. howler/services/action_service.py +111 -0
  172. howler/services/analytic_service.py +128 -0
  173. howler/services/auth_service.py +323 -0
  174. howler/services/config_service.py +128 -0
  175. howler/services/dossier_service.py +252 -0
  176. howler/services/event_service.py +93 -0
  177. howler/services/hit_service.py +893 -0
  178. howler/services/jwt_service.py +158 -0
  179. howler/services/lucene_service.py +286 -0
  180. howler/services/notebook_service.py +119 -0
  181. howler/services/overview_service.py +44 -0
  182. howler/services/template_service.py +45 -0
  183. howler/services/user_service.py +331 -0
  184. howler/utils/__init__.py +0 -0
  185. howler/utils/annotations.py +28 -0
  186. howler/utils/chunk.py +38 -0
  187. howler/utils/dict_utils.py +200 -0
  188. howler/utils/isotime.py +17 -0
  189. howler/utils/list_utils.py +11 -0
  190. howler/utils/lucene.py +77 -0
  191. howler/utils/path.py +27 -0
  192. howler/utils/socket_utils.py +61 -0
  193. howler/utils/str_utils.py +256 -0
  194. howler/utils/uid.py +47 -0
  195. howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
  196. howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
  197. howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
  198. howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
howler/api/v1/user.py ADDED
@@ -0,0 +1,416 @@
1
+ import re
2
+ from hashlib import sha256
3
+ from typing import Any, Optional, cast
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(
132
+ cast(str | None, data.get("classification", None)), data["email"]
133
+ )
134
+
135
+ # Clear non user account data
136
+ avatar = data.pop("avatar", None)
137
+
138
+ if avatar is not None:
139
+ storage.user_avatar.save(username, avatar)
140
+
141
+ try:
142
+ return ok({"success": storage.user.save(username, User(data))})
143
+ except ValueError as e:
144
+ return bad_request(err=str(e))
145
+
146
+
147
+ @generate_swagger_docs()
148
+ @user_api.route("/<username>", methods=["GET"])
149
+ @api_login(audit=False, required_priv=["R"])
150
+ @add_etag(getter=user_service.get_user, check_if_match=True)
151
+ def get_user_account(username: str, server_version: Optional[str] = None, **kwargs):
152
+ """Load the user account information.
153
+
154
+ Variables:
155
+ username => Name of the user to get the account info
156
+
157
+ Arguments:
158
+ load_avatar => If exists, this will load the avatar as well
159
+
160
+ Result Example:
161
+ {
162
+ "name": "Test user", # Name of the user
163
+ "is_active": true, # Is the user active?
164
+ "classification": "", # Max classification for user
165
+ "uname": "usertest", # Username
166
+ "type": ['user'], # List of all types the user is member of
167
+ "avatar": null, # Avatar of the user
168
+ "groups": ["TEST"] # Groups the user is member of
169
+ }
170
+ """
171
+ if username != kwargs["user"]["uname"] and "admin" not in kwargs["user"]["type"]:
172
+ return forbidden(err="You are not allow to view other users then yourself.")
173
+
174
+ user: Optional[User] = kwargs.get("cached_user")
175
+ if not user:
176
+ return not_found(err=f"User {username} does not exist")
177
+
178
+ user: dict[str, Any] = user.as_primitives()
179
+ user["apikeys"] = [(k, []) for k in user.get("apikeys", {}).keys()]
180
+ user["has_password"] = user.pop("password", "") != ""
181
+ user["roles"] = user.pop("type", [])
182
+ user["username"] = user["uname"]
183
+
184
+ if "load_avatar" in request.args:
185
+ user["avatar"] = datastore().user_avatar.get(username)
186
+
187
+ return ok(user), server_version
188
+
189
+
190
+ @generate_swagger_docs()
191
+ @user_api.route("/<username>", methods=["DELETE"])
192
+ @api_login(required_type=["admin"])
193
+ def remove_user_account(username, **_):
194
+ """Remove the account specified by the username.
195
+
196
+ Variables:
197
+ username => Name of the user to get the account info
198
+
199
+ Arguments:
200
+ None
201
+
202
+ Result Example:
203
+ {
204
+ "success": true # Was the remove successful?
205
+ }
206
+ """
207
+ storage = datastore()
208
+ user_data = storage.user.get(username)
209
+ if user_data:
210
+ user_deleted = storage.user.delete(username)
211
+
212
+ if storage.user_avatar.exists(username):
213
+ avatar_deleted = storage.user_avatar.delete(username)
214
+ else:
215
+ avatar_deleted = True
216
+
217
+ if not user_deleted or not avatar_deleted:
218
+ logger.warning("Failed to delete user")
219
+ return internal_error(err="Failed to delete user or avatar. Contact your administrator.")
220
+
221
+ return no_content()
222
+ else:
223
+ return not_found(err=f"User {username} does not exist")
224
+
225
+
226
+ @generate_swagger_docs()
227
+ @user_api.route("/<username>", methods=["PUT"])
228
+ @api_login(required_type=["admin", "user"], enforce_quota=False)
229
+ def set_user_account(username: str, **kwargs): # noqa: C901
230
+ """Save the user account information.
231
+
232
+ Variables:
233
+ username => Name of the user to get the account info
234
+
235
+ Arguments:
236
+ None
237
+
238
+ Data Block:
239
+ {
240
+ "name": "Test user", # Name of the user
241
+ "is_active": true, # Is the user active?
242
+ "classification": "", # Max classification for user
243
+ "uname": "usertest", # Username
244
+ "type": ['user'], # List of all types the user is member of
245
+ "avatar": null, # Avatar of the user
246
+ "groups": ["TEST"] # Groups the user is member of
247
+ }
248
+
249
+ Result Example:
250
+ {
251
+ "success": true # Saving the user info succeded
252
+ }
253
+ """
254
+ try:
255
+ new_data = request.json
256
+ if not isinstance(new_data, dict):
257
+ return bad_request(err="Invalid data format")
258
+
259
+ storage = datastore()
260
+ if not (old_user := storage.user.get_if_exists(username, as_obj=False)):
261
+ return not_found(err=f"User {username} does not exist")
262
+
263
+ data = {**old_user, **new_data}
264
+ new_pass = data.pop("new_pass", None)
265
+
266
+ # Don't allow the overwriting of api keys
267
+ data["apikeys"] = old_user.get("apikeys", [])
268
+
269
+ # Don't allow overwriting of api quota unless you're an admin
270
+ if "admin" not in kwargs["user"]["type"]:
271
+ data["api_quota"] = old_user["api_quota"]
272
+
273
+ if not data["name"]:
274
+ return bad_request(err="Full name of the user cannot be empty")
275
+
276
+ if data["email"] != old_user["email"]:
277
+ return bad_request(err="Cannot update user's email")
278
+
279
+ if data["uname"] != old_user["uname"]:
280
+ return bad_request(err="Cannot update user's username")
281
+
282
+ password_requirements = config.auth.internal.password_requirements.model_dump()
283
+ if not new_pass:
284
+ data["password"] = old_user.get("password", "__NO_PASSWORD__") or "__NO_PASSWORD__"
285
+ elif not check_password_requirements(new_pass, **password_requirements):
286
+ error_msg = get_password_requirement_message(**password_requirements)
287
+ return bad_request(err=error_msg)
288
+ else:
289
+ data["password"] = get_password_hash(new_pass)
290
+ data.pop("new_pass_confirm", None)
291
+
292
+ # Apply dynamic classification
293
+ data["classification"] = user_service.get_dynamic_classification(data["classification"], data["email"])
294
+
295
+ ret_val = user_service.save_user_account(username, data, kwargs["user"])
296
+ return ok({"success": ret_val})
297
+ except AccessDeniedException as e:
298
+ return forbidden(err=str(e))
299
+ except (InvalidDataException, HowlerValueError) as e:
300
+ return bad_request(err=str(e))
301
+
302
+
303
+ ######################################################
304
+ # User's Avatar
305
+ ######################################################
306
+
307
+
308
+ @generate_swagger_docs()
309
+ @user_api.route("/avatar/<username>", methods=["GET"])
310
+ @api_login(audit=True, required_priv=["R"])
311
+ def get_user_avatar(username, **_):
312
+ """Loads the user's avatar.
313
+
314
+ Variables:
315
+ username => Name of the user you want to get the avatar for
316
+
317
+ Arguments:
318
+ None
319
+
320
+ Result Example:
321
+ "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
322
+ """
323
+ storage = datastore()
324
+ avatar: str = storage.user_avatar.get(username)
325
+
326
+ if avatar:
327
+ resp = ok(avatar)
328
+ resp.headers["Cache-Control"] = "private, max-age=3600"
329
+ resp.headers["ETag"] = sha256(avatar.encode("utf-8")).hexdigest()
330
+ return resp
331
+ else:
332
+ return no_content()
333
+
334
+
335
+ @generate_swagger_docs()
336
+ @user_api.route("/avatar/<username>", methods=["POST"])
337
+ @api_login(audit=True)
338
+ def set_user_avatar(username, **kwargs):
339
+ """Sets the user's Avatar
340
+
341
+ Variables:
342
+ username => Name of the user you want to set the avatar for
343
+
344
+ Arguments:
345
+ None
346
+
347
+ Data Block:
348
+ "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
349
+
350
+ Result Example:
351
+ {
352
+ "success": true # Was saving the avatar successful ?
353
+ }
354
+ """
355
+ if username != kwargs["user"]["uname"]:
356
+ return forbidden(err="Cannot save the avatar of another user.")
357
+
358
+ data = request.data
359
+ storage = datastore()
360
+ if data:
361
+ data: str = data.decode("utf-8")
362
+ if not isinstance(data, str) or not storage.user_avatar.save(username, data):
363
+ bad_request(
364
+ err="Data block should be a base64 encoded image that starts with 'data:image/<format>;base64,'"
365
+ )
366
+ else:
367
+ storage.user_avatar.delete(username)
368
+
369
+ return ok()
370
+
371
+
372
+ @generate_swagger_docs()
373
+ @user_api.route("/groups", methods=["GET"])
374
+ @api_login(audit=False)
375
+ def get_user_groups(**kwargs):
376
+ """Gets the user's groups from an oauth provider
377
+
378
+ Variables:
379
+ None
380
+
381
+ Arguments:
382
+ None
383
+
384
+ Result Example:
385
+ [
386
+ {
387
+ "name": "Group Name",
388
+ "id": "abc-123"
389
+ },
390
+ ...
391
+ ]
392
+ """
393
+ auth_header = request.headers.get("Authorization", None)
394
+
395
+ if not auth_header:
396
+ raise AuthenticationException("No Authorization header present")
397
+
398
+ type, token = auth_header.split(" ")
399
+
400
+ group_data = None
401
+ if type == "Bearer" and "." in token:
402
+ try:
403
+ group_data = fetch_groups(token)
404
+ except HowlerException as e:
405
+ return internal_error(e.message)
406
+
407
+ if group_data is None:
408
+ group_data = []
409
+ for g in kwargs["user"].get("groups", []):
410
+ name = re.sub(r"^\w", lambda m: m.group(0).upper(), g)
411
+ name = re.sub(r"[-_]", " ", name)
412
+ name = re.sub(r" \w", lambda m: m.group(0).upper(), name)
413
+
414
+ group_data.append({"name": name, "id": g})
415
+
416
+ 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