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.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from typing import Any, Optional, Union
|
|
2
|
+
|
|
3
|
+
import elasticapm
|
|
4
|
+
from authlib.integrations.flask_client import OAuth
|
|
5
|
+
from flask import current_app, request
|
|
6
|
+
|
|
7
|
+
from howler.common.exceptions import AccessDeniedException, HowlerValueError, InvalidDataException
|
|
8
|
+
from howler.common.loader import datastore
|
|
9
|
+
from howler.common.logging import get_logger
|
|
10
|
+
from howler.config import CLASSIFICATION, config
|
|
11
|
+
from howler.helper.oauth import fetch_avatar, parse_profile
|
|
12
|
+
from howler.odm.models.user import User
|
|
13
|
+
from howler.odm.models.view import View
|
|
14
|
+
from howler.utils.str_utils import safe_str
|
|
15
|
+
|
|
16
|
+
ACCOUNT_USER_MODIFIABLE = ["name", "email", "avatar", "password", "dashboard"]
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__file__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_user(
|
|
22
|
+
id: str,
|
|
23
|
+
as_odm: bool = False,
|
|
24
|
+
version: bool = False,
|
|
25
|
+
) -> Union[User, dict[str, Any]]:
|
|
26
|
+
"""Return hit object as either an ODM or Dict"""
|
|
27
|
+
return datastore().user.get_if_exists(key=id, as_obj=as_odm, version=version)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def convert_user(user: User) -> dict[str, Any]:
|
|
31
|
+
"""Converts a User ODM into a dict for frontend usage, stripping out private or irrelevant fields.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
user (User): The user object to parse
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
dict: The parsed user data object
|
|
38
|
+
"""
|
|
39
|
+
user_data = {
|
|
40
|
+
k: v
|
|
41
|
+
for k, v in user.as_primitives().items()
|
|
42
|
+
if k
|
|
43
|
+
in [
|
|
44
|
+
"classification",
|
|
45
|
+
"email",
|
|
46
|
+
"groups",
|
|
47
|
+
"is_active",
|
|
48
|
+
"name",
|
|
49
|
+
"type",
|
|
50
|
+
"uname",
|
|
51
|
+
"api_quota",
|
|
52
|
+
"favourite_views",
|
|
53
|
+
"favourite_analytics",
|
|
54
|
+
"dashboard",
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
user_data["apikeys"] = [
|
|
59
|
+
(key, value["acl"], value["expiry_date"])
|
|
60
|
+
for key, value in datastore().user.get_if_exists(user["uname"]).apikeys.items()
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
user_data["avatar"] = datastore().user_avatar.get_if_exists(user["uname"])
|
|
64
|
+
user_data["username"] = user_data.pop("uname")
|
|
65
|
+
user_data["is_admin"] = "admin" in user_data["type"]
|
|
66
|
+
user_data["roles"] = list(set(user_data.pop("type")))
|
|
67
|
+
|
|
68
|
+
return user_data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@elasticapm.capture_span(span_type="authentication")
|
|
72
|
+
def parse_user_data( # noqa: C901
|
|
73
|
+
data: dict,
|
|
74
|
+
oauth_provider: str,
|
|
75
|
+
skip_setup: bool = True,
|
|
76
|
+
access_token: Optional[str] = None,
|
|
77
|
+
) -> User:
|
|
78
|
+
"""Convert a JSON Web Token into a Howler User.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
data (dict): The JWT to parse
|
|
82
|
+
oauth_provider (str): The provider of the JWT
|
|
83
|
+
skip_setup (bool, optional): Skip the extra setup steps we run at login, for performance reasons.
|
|
84
|
+
Defaults to True.
|
|
85
|
+
access_token (str, optional): The access token to use when fetching the user's avatar. Defaults to None.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
InvalidDataException: Some required data was missing.
|
|
89
|
+
AccessDeniedException: The user is not permitted to access the application, or user auto-creation is disabled
|
|
90
|
+
and the user doesn't exist in the database.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
User: The parsed User ODM
|
|
94
|
+
"""
|
|
95
|
+
if not data or not oauth_provider:
|
|
96
|
+
raise InvalidDataException("Both the JWT and OAuth provider must be supplied")
|
|
97
|
+
|
|
98
|
+
oauth = current_app.extensions.get("authlib.integrations.flask_client")
|
|
99
|
+
if not oauth: # pragma: no cover
|
|
100
|
+
logger.critical("Authlib integration missing!")
|
|
101
|
+
raise HowlerValueError("Authlib integration missing!")
|
|
102
|
+
|
|
103
|
+
provider: OAuth = oauth.create_client(oauth_provider)
|
|
104
|
+
|
|
105
|
+
if "id_token" in data:
|
|
106
|
+
data = provider.parse_id_token(
|
|
107
|
+
data, nonce=request.args.get("nonce", data.get("userinfo", {}).get("nonce", None))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
oauth_provider_config = config.auth.oauth.providers[oauth_provider]
|
|
111
|
+
|
|
112
|
+
if not data and oauth_provider_config.user_get:
|
|
113
|
+
response = provider.get(oauth_provider_config.user_get)
|
|
114
|
+
if response.ok:
|
|
115
|
+
data = response.json()
|
|
116
|
+
|
|
117
|
+
user_data = parse_profile(data, oauth_provider_config)
|
|
118
|
+
|
|
119
|
+
if len(oauth_provider_config.required_groups) > 0:
|
|
120
|
+
required_groups = set(oauth_provider_config.required_groups)
|
|
121
|
+
if len(required_groups) != len(required_groups & set(user_data["groups"])):
|
|
122
|
+
logger.warning(
|
|
123
|
+
f"User {user_data['uname']} is missing groups from their JWT:"
|
|
124
|
+
f" {', '.join(required_groups - (required_groups & set(user_data['groups'])))}"
|
|
125
|
+
)
|
|
126
|
+
raise AccessDeniedException("This user is not allowed access to the system")
|
|
127
|
+
|
|
128
|
+
has_access = user_data.pop("access", False)
|
|
129
|
+
storage = datastore()
|
|
130
|
+
current_user: Optional[dict[str, Any]] = None
|
|
131
|
+
if has_access and user_data["email"] is not None:
|
|
132
|
+
# Find if user already exists
|
|
133
|
+
users: list[dict[str, Any]] = storage.user.search(f"email:{user_data['email']}", fl="*", as_obj=False)["items"]
|
|
134
|
+
|
|
135
|
+
if users:
|
|
136
|
+
current_user = users[0]
|
|
137
|
+
# Do not update username and password from the current user
|
|
138
|
+
user_data["uname"] = current_user.get("uname", user_data["uname"])
|
|
139
|
+
user_data.pop("password", None)
|
|
140
|
+
else:
|
|
141
|
+
if user_data["uname"] != user_data["email"]:
|
|
142
|
+
# Username was computed using a regular expression, lets make sure we don't
|
|
143
|
+
# assign the same username to two users
|
|
144
|
+
exists = storage.user.exists(user_data["uname"])
|
|
145
|
+
if exists:
|
|
146
|
+
count = 1
|
|
147
|
+
new_uname = f"{user_data['uname']}{count}"
|
|
148
|
+
while storage.user.exists(new_uname):
|
|
149
|
+
count += 1
|
|
150
|
+
new_uname = f"{user_data['uname']}{count}"
|
|
151
|
+
user_data["uname"] = new_uname
|
|
152
|
+
current_user = {}
|
|
153
|
+
|
|
154
|
+
username = user_data["uname"]
|
|
155
|
+
|
|
156
|
+
# Add add dynamic classification group
|
|
157
|
+
user_data["classification"] = get_dynamic_classification(user_data["classification"], user_data["email"])
|
|
158
|
+
|
|
159
|
+
# Make sure the user exists in howler and is in sync
|
|
160
|
+
if (not current_user and oauth_provider_config.auto_create) or (
|
|
161
|
+
current_user and oauth_provider_config.auto_sync
|
|
162
|
+
):
|
|
163
|
+
old_user = {**current_user}
|
|
164
|
+
old_user.pop("id", None)
|
|
165
|
+
old_user.pop("avatar", None)
|
|
166
|
+
|
|
167
|
+
# Update the current user
|
|
168
|
+
current_user.update(user_data)
|
|
169
|
+
|
|
170
|
+
user_id = current_user.pop("id", None)
|
|
171
|
+
avatar = current_user.pop("avatar", None)
|
|
172
|
+
|
|
173
|
+
# Save updated user if there are changes to sync or it doesn't exist
|
|
174
|
+
if old_user != current_user:
|
|
175
|
+
if user_id:
|
|
176
|
+
logger.info("Updating %s with new data", user_id if not isinstance(user_id, list) else user_id[0])
|
|
177
|
+
else:
|
|
178
|
+
logger.info("Creating new user %s", username)
|
|
179
|
+
|
|
180
|
+
if user_id:
|
|
181
|
+
current_user["id"] = user_id
|
|
182
|
+
|
|
183
|
+
if avatar:
|
|
184
|
+
current_user["avatar"] = avatar
|
|
185
|
+
|
|
186
|
+
storage.user.save(username, current_user)
|
|
187
|
+
storage.user.commit()
|
|
188
|
+
|
|
189
|
+
if not skip_setup:
|
|
190
|
+
if avatar:
|
|
191
|
+
logger.info("Updating avatar for %s", username)
|
|
192
|
+
|
|
193
|
+
avatar = fetch_avatar(
|
|
194
|
+
avatar,
|
|
195
|
+
provider,
|
|
196
|
+
oauth_provider,
|
|
197
|
+
access_token=access_token,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if avatar:
|
|
201
|
+
storage.user_avatar.save(username, avatar)
|
|
202
|
+
|
|
203
|
+
view_query = f"owner:{current_user['uname']} AND title:view.assigned_to_me AND type:readonly"
|
|
204
|
+
if len(storage.view.search(view_query)["items"]) == 0:
|
|
205
|
+
new_assigned_view = View(
|
|
206
|
+
{
|
|
207
|
+
"title": "view.assigned_to_me",
|
|
208
|
+
"query": f"howler.assignment:{current_user['uname']}",
|
|
209
|
+
"type": "readonly",
|
|
210
|
+
"owner": current_user["uname"],
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
current_user["favourite_views"] = [
|
|
215
|
+
*current_user.get("favourite_views", []),
|
|
216
|
+
new_assigned_view.view_id,
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
storage.view.save(new_assigned_view.view_id, new_assigned_view)
|
|
220
|
+
storage.user.save(username, current_user)
|
|
221
|
+
storage.user.commit()
|
|
222
|
+
|
|
223
|
+
if not current_user:
|
|
224
|
+
raise AccessDeniedException("User auto-creation is disabled")
|
|
225
|
+
else:
|
|
226
|
+
raise AccessDeniedException("This user is not allowed access to the system")
|
|
227
|
+
|
|
228
|
+
return User(current_user)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def add_access_control(user: dict[str, Any]):
|
|
232
|
+
"""Add access control to the specified user.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
user (dict[str, Any]): The user to add access control information to.
|
|
236
|
+
"""
|
|
237
|
+
user.update(
|
|
238
|
+
CLASSIFICATION.get_access_control_parts(
|
|
239
|
+
user.get("classification", CLASSIFICATION.UNRESTRICTED),
|
|
240
|
+
user_classification=True,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
gl2_query = " OR ".join(
|
|
245
|
+
[
|
|
246
|
+
"__access_grp2__:__EMPTY__",
|
|
247
|
+
*[f'__access_grp2__:"{x}"' for x in user["__access_grp2__"]],
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
gl2_query = f"({gl2_query}) AND "
|
|
251
|
+
|
|
252
|
+
gl1_query = " OR ".join(
|
|
253
|
+
[
|
|
254
|
+
"__access_grp1__:__EMPTY__",
|
|
255
|
+
*[f'__access_grp1__:"{x}"' for x in user["__access_grp1__"]],
|
|
256
|
+
],
|
|
257
|
+
)
|
|
258
|
+
gl1_query = f"({gl1_query}) AND "
|
|
259
|
+
|
|
260
|
+
req = list(set(CLASSIFICATION.get_access_control_req()).difference(set(user["__access_req__"])))
|
|
261
|
+
req_query = " OR ".join([f'__access_req__:"{r}"' for r in req])
|
|
262
|
+
if req_query:
|
|
263
|
+
req_query = f"-({req_query}) AND "
|
|
264
|
+
|
|
265
|
+
lvl_query = f'__access_lvl__:[0 TO {user["__access_lvl__"]}]'
|
|
266
|
+
|
|
267
|
+
query = f"{gl2_query}{gl1_query}{req_query}{lvl_query}"
|
|
268
|
+
user["access_control"] = safe_str(query)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def save_user_account(username: str, data: dict[str, Any], user: dict[str, Any]) -> bool:
|
|
272
|
+
"""Create or update a user in the database
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
username (str): The username to create or update the user under
|
|
276
|
+
data (dict[str, Any]): The user's data
|
|
277
|
+
user (dict[str, Any]): The account that is creating this new user
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
AccessDeniedException: Parts of the user data is overwriting fields that cannot be changed.
|
|
281
|
+
InvalidDataException: The username in question doesn't match any existing users
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
bool: If the save operation was successful
|
|
285
|
+
"""
|
|
286
|
+
# Clear non user account data
|
|
287
|
+
avatar = data.pop("avatar", None)
|
|
288
|
+
data.pop("security_token_enabled", None)
|
|
289
|
+
data.pop("has_password", None)
|
|
290
|
+
|
|
291
|
+
data = User(data).as_primitives()
|
|
292
|
+
|
|
293
|
+
if username != data["uname"]:
|
|
294
|
+
raise AccessDeniedException("You are not allowed to change the username.")
|
|
295
|
+
|
|
296
|
+
if username != user["uname"] and "admin" not in user["type"]:
|
|
297
|
+
raise AccessDeniedException("You are not allowed to change another user than yourself.")
|
|
298
|
+
|
|
299
|
+
storage = datastore()
|
|
300
|
+
current = storage.user.get_if_exists(username, as_obj=False)
|
|
301
|
+
if current:
|
|
302
|
+
if "admin" not in user["type"]:
|
|
303
|
+
for key in current.keys():
|
|
304
|
+
if data[key] != current[key] and key not in ACCOUNT_USER_MODIFIABLE:
|
|
305
|
+
raise AccessDeniedException(f"Only Administrators can change the value of the field [{key}].")
|
|
306
|
+
else:
|
|
307
|
+
raise InvalidDataException(f"You cannot save a user that does not exists [{username}].")
|
|
308
|
+
|
|
309
|
+
if avatar == "DELETE":
|
|
310
|
+
storage.user_avatar.delete(username)
|
|
311
|
+
elif avatar is not None:
|
|
312
|
+
storage.user_avatar.save(username, avatar)
|
|
313
|
+
|
|
314
|
+
return storage.user.save(username, data)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_dynamic_classification(current_c12n: str | None, email: str) -> str | None:
|
|
318
|
+
"""Get the classification of the user
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
current_c12n (str): The current classification of the user
|
|
322
|
+
email (str): The user's email
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
str: The classification
|
|
326
|
+
"""
|
|
327
|
+
if CLASSIFICATION.dynamic_groups and email:
|
|
328
|
+
dyn_group = email.upper().split("@")[1]
|
|
329
|
+
return CLASSIFICATION.build_user_classification(current_c12n, f"{CLASSIFICATION.UNRESTRICTED}//{dyn_group}")
|
|
330
|
+
|
|
331
|
+
return current_c12n
|
howler/utils/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
def docstring_parameters(**kwargs: dict[str, str]): # pragma: no cover
|
|
2
|
+
"""Substitute variables in docstring.
|
|
3
|
+
|
|
4
|
+
This annotation modifies the docstring of an objects to insure that Howler's dynamic api documentation is
|
|
5
|
+
always up to date.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
substitutions (dict[str, Any]): Dictionary of substitutions
|
|
9
|
+
**args (str): Individual substitutions
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
None: This annotation directly modifies an object's docstring
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
@docstring_parameters({cake="Black Forest", topping="Cherry"})\n
|
|
16
|
+
def bake():\n
|
|
17
|
+
'''Bake a cake of flavour $(cake) with topping $(topping)'''\n
|
|
18
|
+
|
|
19
|
+
@docstring_parameters(danger="low")\n
|
|
20
|
+
def assess():\n
|
|
21
|
+
'''This docstring's danger level is $(danger)'''
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def dec(obj):
|
|
25
|
+
obj.__doc__ = obj.__doc__ % ({**kwargs})
|
|
26
|
+
return obj
|
|
27
|
+
|
|
28
|
+
return dec
|
howler/utils/chunk.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Sequence manipulation methods used in parsing raw datastore output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generator, Sequence, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
_T = TypeVar("_T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@overload
|
|
11
|
+
def chunk(items: bytes, n: int) -> Generator[bytes, None, None]: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@overload
|
|
15
|
+
def chunk(items: str, n: int) -> Generator[str, None, None]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@overload
|
|
19
|
+
def chunk(items: Sequence[_T], n: int) -> Generator[Sequence[_T], None, None]: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def chunk(items, n: int):
|
|
23
|
+
"""Yield n-sized chunks from list.
|
|
24
|
+
|
|
25
|
+
>>> list(chunk([1,2,3,4,5,6,7], 2))
|
|
26
|
+
[[1,2], [3,4], [5,6], [7,]]
|
|
27
|
+
"""
|
|
28
|
+
for i in range(0, len(items), n):
|
|
29
|
+
yield items[i : i + n]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def chunked_list(items: Sequence[_T], n: int) -> list[Sequence[_T]]:
|
|
33
|
+
"""Create a list of n-sized chunks from list.
|
|
34
|
+
|
|
35
|
+
>>> chunked_list([1,2,3,4,5,6,7], 2)
|
|
36
|
+
[[1,2], [3,4], [5,6], [7,]]
|
|
37
|
+
"""
|
|
38
|
+
return list(chunk(items, n))
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import TYPE_CHECKING, Any, AnyStr, Optional, cast
|
|
3
|
+
from typing import Mapping as _Mapping
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from howler.odm.base import Model, _Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def strip_nulls(d: Any):
|
|
10
|
+
"""Remove null values from a dict"""
|
|
11
|
+
if isinstance(d, dict):
|
|
12
|
+
return {k: strip_nulls(v) for k, v in d.items() if v is not None}
|
|
13
|
+
else:
|
|
14
|
+
return d
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def recursive_update(
|
|
18
|
+
d: Optional[dict[str, Any]],
|
|
19
|
+
u: Optional[_Mapping[str, Any]],
|
|
20
|
+
stop_keys: list[AnyStr] = [],
|
|
21
|
+
allow_recursion: bool = True,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"Recursively update a dict with another value"
|
|
24
|
+
if d is None:
|
|
25
|
+
return cast(dict, u or {})
|
|
26
|
+
|
|
27
|
+
if u is None:
|
|
28
|
+
return d
|
|
29
|
+
|
|
30
|
+
for k, v in u.items():
|
|
31
|
+
if isinstance(v, Mapping) and allow_recursion:
|
|
32
|
+
d[k] = recursive_update(d.get(k, {}), v, stop_keys=stop_keys, allow_recursion=k not in stop_keys)
|
|
33
|
+
else:
|
|
34
|
+
d[k] = v
|
|
35
|
+
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_recursive_delta(
|
|
40
|
+
d1: Optional[_Mapping[str, Any]],
|
|
41
|
+
d2: Optional[_Mapping[str, Any]],
|
|
42
|
+
stop_keys: list[AnyStr] = [],
|
|
43
|
+
allow_recursion: bool = True,
|
|
44
|
+
) -> Optional[dict[str, Any]]:
|
|
45
|
+
"Get the recursive difference between two objects"
|
|
46
|
+
if d1 is None:
|
|
47
|
+
return cast(dict, d2)
|
|
48
|
+
|
|
49
|
+
if d2 is None:
|
|
50
|
+
return cast(dict, d1)
|
|
51
|
+
|
|
52
|
+
out = {}
|
|
53
|
+
for k1, v1 in d1.items():
|
|
54
|
+
if isinstance(v1, Mapping) and allow_recursion:
|
|
55
|
+
internal = get_recursive_delta(
|
|
56
|
+
v1,
|
|
57
|
+
d2.get(k1, {}),
|
|
58
|
+
stop_keys=stop_keys,
|
|
59
|
+
allow_recursion=k1 not in stop_keys,
|
|
60
|
+
)
|
|
61
|
+
if internal:
|
|
62
|
+
out[k1] = internal
|
|
63
|
+
else:
|
|
64
|
+
if k1 in d2:
|
|
65
|
+
v2 = d2[k1]
|
|
66
|
+
if v1 != v2:
|
|
67
|
+
out[k1] = v2
|
|
68
|
+
|
|
69
|
+
for k2, v2 in d2.items():
|
|
70
|
+
if k2 not in d1:
|
|
71
|
+
out[k2] = v2
|
|
72
|
+
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def flatten(data: _Mapping, parent_key: Optional[str] = None, odm: Optional[type["Model"]] = None) -> dict[str, Any]:
|
|
77
|
+
"Flatten a nested dict"
|
|
78
|
+
items: list[tuple[str, Any]] = []
|
|
79
|
+
for k, v in data.items():
|
|
80
|
+
cur_key = f"{parent_key}.{k}" if parent_key is not None else k
|
|
81
|
+
|
|
82
|
+
if isinstance(v, dict):
|
|
83
|
+
if odm:
|
|
84
|
+
valid_keys = list(odm.flat_fields().keys())
|
|
85
|
+
if not next((key for key in valid_keys if key.startswith(f"{cur_key}.")), False):
|
|
86
|
+
items.append((cur_key, v))
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
items.extend(flatten(v, cur_key, odm=odm).items())
|
|
90
|
+
else:
|
|
91
|
+
items.append((cur_key, v))
|
|
92
|
+
|
|
93
|
+
return dict(items)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def flatten_deep(data: _Mapping):
|
|
97
|
+
"Aggressively and completely flatten an object."
|
|
98
|
+
partially_flattened = flatten(data)
|
|
99
|
+
|
|
100
|
+
final: dict[str, Any] = {}
|
|
101
|
+
for key, value in partially_flattened.items():
|
|
102
|
+
if not isinstance(value, list) or len(value) == 0 or all(not isinstance(entry, dict) for entry in value):
|
|
103
|
+
final[key] = value
|
|
104
|
+
else:
|
|
105
|
+
for entry in value:
|
|
106
|
+
flat_value = flatten_deep(entry)
|
|
107
|
+
for child_key, child_value in flat_value.items():
|
|
108
|
+
full_key = f"{key}.{child_key}"
|
|
109
|
+
if full_key not in final:
|
|
110
|
+
if isinstance(child_value, list):
|
|
111
|
+
final[full_key] = child_value
|
|
112
|
+
else:
|
|
113
|
+
final[full_key] = [child_value]
|
|
114
|
+
else:
|
|
115
|
+
if isinstance(child_value, list):
|
|
116
|
+
final[full_key].extend(child_value)
|
|
117
|
+
else:
|
|
118
|
+
final[full_key].append(child_value)
|
|
119
|
+
|
|
120
|
+
return final
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def unflatten(data: _Mapping) -> _Mapping:
|
|
124
|
+
"Unflatten a nested dict"
|
|
125
|
+
out: dict[str, Any] = dict()
|
|
126
|
+
for k, v in data.items():
|
|
127
|
+
parts = k.split(".")
|
|
128
|
+
d = out
|
|
129
|
+
for p in parts[:-1]:
|
|
130
|
+
if p not in d:
|
|
131
|
+
d[p] = dict()
|
|
132
|
+
d = d[p]
|
|
133
|
+
d[parts[-1]] = v
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def extra_keys(odm: type["Model"], data: _Mapping) -> set[str]:
|
|
138
|
+
"Geta list of extra keys when compared to a list of permitted keys"
|
|
139
|
+
from howler.odm.base import Mapping, Optional
|
|
140
|
+
|
|
141
|
+
data = flatten_deep(data)
|
|
142
|
+
|
|
143
|
+
result: set[str] = set()
|
|
144
|
+
for key in data.keys():
|
|
145
|
+
parts = key.split(".")
|
|
146
|
+
current_odm = odm
|
|
147
|
+
for part in parts:
|
|
148
|
+
sub_fields: dict[str, Any] = current_odm.fields()
|
|
149
|
+
|
|
150
|
+
if part in sub_fields:
|
|
151
|
+
current_odm = sub_fields[part]
|
|
152
|
+
else:
|
|
153
|
+
if isinstance(current_odm, Optional):
|
|
154
|
+
current_odm = current_odm.child_type
|
|
155
|
+
|
|
156
|
+
if isinstance(current_odm, Mapping):
|
|
157
|
+
current_odm = current_odm.child_type
|
|
158
|
+
else:
|
|
159
|
+
result.add(key)
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def prune( # noqa: C901
|
|
166
|
+
data: _Mapping, keys: list[str], fields: dict[str, "_Field"], mapping_class: type, parent_key: Optional[str] = None
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"Remove all keys in the given list from the dict if they exist"
|
|
169
|
+
pruned_items: list[tuple[str, Any]] = []
|
|
170
|
+
|
|
171
|
+
for key, val in data.items():
|
|
172
|
+
cur_key = f"{parent_key}.{key}" if parent_key else key
|
|
173
|
+
|
|
174
|
+
# If this key is a mapping, preserve all children
|
|
175
|
+
if isinstance(fields.get(cur_key, None), mapping_class):
|
|
176
|
+
pruned_items.append((key, val))
|
|
177
|
+
elif isinstance(val, dict):
|
|
178
|
+
child_keys = [_key for _key in keys if _key.startswith(cur_key)]
|
|
179
|
+
|
|
180
|
+
if len(child_keys) > 0:
|
|
181
|
+
pruned_items.append((key, prune(val, child_keys, fields, mapping_class, cur_key)))
|
|
182
|
+
elif isinstance(val, list):
|
|
183
|
+
if cur_key not in keys and not any(_key.startswith(cur_key) for _key in keys):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
list_result = []
|
|
187
|
+
for entry in val:
|
|
188
|
+
if isinstance(val, dict):
|
|
189
|
+
child_keys = [_key for _key in keys if _key.startswith(cur_key)]
|
|
190
|
+
|
|
191
|
+
if len(child_keys) > 0:
|
|
192
|
+
pruned_items.append((key, prune(val, child_keys, fields, mapping_class, cur_key)))
|
|
193
|
+
else:
|
|
194
|
+
list_result.append(entry)
|
|
195
|
+
|
|
196
|
+
pruned_items.append((key, list_result))
|
|
197
|
+
elif cur_key in keys:
|
|
198
|
+
pruned_items.append((key, val))
|
|
199
|
+
|
|
200
|
+
return {k: v for k, v in pruned_items}
|
howler/utils/isotime.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
# DO NOT REMOVE!!! THIS IS MAGIC!
|
|
5
|
+
# strptime Thread safe fix... yeah ...
|
|
6
|
+
datetime.strptime("2000", "%Y")
|
|
7
|
+
# END OF MAGIC
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def now_as_iso() -> str:
|
|
11
|
+
"""Get the current time as an ISO formatted string"""
|
|
12
|
+
if sys.version_info.minor < 11:
|
|
13
|
+
return f"{datetime.utcnow().isoformat()}Z"
|
|
14
|
+
else:
|
|
15
|
+
from datetime import UTC
|
|
16
|
+
|
|
17
|
+
return datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|