howler-api 3.3.0.dev767__py3-none-any.whl → 3.3.0.dev768__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.
howler/api/v1/user.py CHANGED
@@ -128,9 +128,7 @@ def add_user_account(username, **_):
128
128
  data["name"] = data["uname"]
129
129
 
130
130
  # Add dynamic classification group
131
- data["classification"] = user_service.get_dynamic_classification(
132
- cast(str | None, data.get("classification", None)), data["email"]
133
- )
131
+ data["classification"] = user_service.get_dynamic_classification(data)
134
132
 
135
133
  # Clear non user account data
136
134
  avatar = data.pop("avatar", None)
@@ -290,7 +288,7 @@ def set_user_account(username: str, **kwargs): # noqa: C901
290
288
  data.pop("new_pass_confirm", None)
291
289
 
292
290
  # Apply dynamic classification
293
- data["classification"] = user_service.get_dynamic_classification(data["classification"], data["email"])
291
+ data["classification"] = user_service.get_dynamic_classification(data)
294
292
 
295
293
  ret_val = user_service.save_user_account(username, data, kwargs["user"])
296
294
  return ok({"success": ret_val})
@@ -38,7 +38,7 @@ class Classification(object):
38
38
  "description",
39
39
  ]
40
40
  self.original_definition = classification_definition
41
- self.levels_map: dict[str, str] = {}
41
+ self.levels_map: dict[str, int] = {}
42
42
  self.levels_map_stl = {}
43
43
  self.levels_map_lts = {}
44
44
  self.levels_styles_map = {}
@@ -64,6 +64,9 @@ class Classification(object):
64
64
 
65
65
  self.enforce = False
66
66
  self.dynamic_groups = False
67
+ # dynamic group type is one of: email | group | all
68
+ # defaults to email for original behavior
69
+ self.dynamic_groups_type = "email"
67
70
 
68
71
  # Add Invalid classification
69
72
  self.levels_map["INV"] = self.INVALID_LVL
@@ -84,12 +87,10 @@ class Classification(object):
84
87
  raise HowlerKeyError("Enforce not set!")
85
88
 
86
89
  self.dynamic_groups = classification_definition.get("dynamic_groups", None)
87
- if self.enforce is None:
90
+ if self.dynamic_groups is None:
88
91
  raise HowlerKeyError("Dynamic groups not set!")
89
92
 
90
- if self.enforce:
91
- self._classification_cache = self.list_all_classification_combinations()
92
- self._classification_cache_short = self.list_all_classification_combinations(long_format=False)
93
+ self.dynamic_groups_type = classification_definition.get("dynamic_groups_type", self.dynamic_groups_type)
93
94
 
94
95
  if classification_definition.get("levels", None) is None:
95
96
  raise HowlerKeyError("No classification levels provided!")
@@ -186,6 +187,16 @@ class Classification(object):
186
187
  self.description[short_name] = x.get("description", "N/A")
187
188
  self.description[name] = self.description[short_name]
188
189
 
190
+ # Build the classification cache AFTER all maps are populated so
191
+ # that normalize_classification can fully validate each combination.
192
+ # Using normalized=True filters out invalid combos (e.g. subgroups
193
+ # that violate limited_to_group constraints).
194
+ if self.enforce:
195
+ self._classification_cache = self.list_all_classification_combinations(normalized=True)
196
+ self._classification_cache_short = self.list_all_classification_combinations(
197
+ long_format=False, normalized=True
198
+ )
199
+
189
200
  if not self.is_valid(classification_definition["unrestricted"]):
190
201
  raise InvalidDefinition("Classification definition's unrestricted classification is invalid.")
191
202
 
@@ -238,40 +249,46 @@ class Classification(object):
238
249
 
239
250
  return items
240
251
 
241
- def _get_c12n_level_index(self, c12n: str) -> str:
252
+ def _get_c12n_level_index(self, c12n: str) -> tuple[int, str]:
242
253
  # Parse classifications in uppercase mode only
243
254
  c12n = c12n.upper()
244
255
 
245
- lvl = c12n.split("//")[0]
256
+ lvl, _, remain = c12n.partition("//")
246
257
  if lvl in self.levels_map:
247
- return self.levels_map[lvl]
258
+ return self.levels_map[lvl], remain
248
259
  elif lvl in self.levels_map_lts:
249
- return self.levels_map[self.levels_map_lts[lvl]]
260
+ return self.levels_map[self.levels_map_lts[lvl]], remain
250
261
  elif lvl in self.levels_aliases:
251
- return self.levels_map[self.levels_aliases[lvl]]
262
+ return self.levels_map[self.levels_aliases[lvl]], remain
252
263
  else:
253
264
  raise InvalidClassification(
254
265
  "Classification level '%s' was not found in your classification definition." % lvl
255
266
  )
256
267
 
257
268
  def _get_c12n_level_text(self, lvl_idx: int, long_format: bool = True) -> str:
258
- text = self.levels_map.get(str(lvl_idx), None)
269
+ text: Any = self.levels_map.get(str(lvl_idx), None)
270
+
259
271
  if not text:
260
272
  raise InvalidClassification(
261
273
  "Classification level number '%s' was not found in your classification definition." % lvl_idx
262
274
  )
275
+
263
276
  if long_format:
264
277
  return self.levels_map_stl[text]
278
+
265
279
  return text
266
280
 
267
- def _get_c12n_required(self, c12n: str, long_format: bool = True) -> List:
281
+ def _get_c12n_required(self, c12n: str, long_format: bool = True) -> tuple[List, list[str]]:
268
282
  # Parse classifications in uppercase mode only
269
283
  c12n = c12n.upper()
270
284
 
271
285
  return_set = set()
272
286
  part_set = set(c12n.split("/"))
287
+ unused = []
273
288
 
274
289
  for p in part_set:
290
+ if not p:
291
+ continue
275
292
  if p in self.access_req_map_lts:
276
293
  return_set.add(self.access_req_map_lts[p])
277
294
  elif p in self.access_req_map_stl:
@@ -279,27 +296,37 @@ class Classification(object):
279
296
  elif p in self.access_req_aliases:
280
297
  for a in self.access_req_aliases[p]:
281
298
  return_set.add(a)
299
+ else:
300
+ unused.append(p)
282
301
 
283
302
  if long_format:
284
- return sorted([self.access_req_map_stl[r] for r in return_set])
285
- return sorted(list(return_set))
303
+ return sorted([self.access_req_map_stl[r] for r in return_set]), unused
304
+ return sorted(list(return_set)), unused
286
305
 
287
- def _get_c12n_groups(self, c12n: str, long_format: bool = True) -> Tuple[List, List]:
306
+ def _get_c12n_groups(
307
+ self, c12n: list[str], long_format: bool = True, get_dynamic_groups: bool = True, auto_select: bool = False
308
+ ) -> Tuple[List, List, list[str]]:
288
309
  # Parse classifications in uppercase mode only
289
- c12n = c12n.upper()
290
310
 
291
311
  g1_set = set()
292
312
  g2_set = set()
293
313
  others = set()
294
314
 
295
- grp_part = c12n.split("//")
296
315
  groups = []
297
- for gp in grp_part:
298
- gp = gp.replace("REL TO ", "")
299
- gp = gp.replace("REL ", "")
300
- temp_group = set([x.strip() for x in gp.split(",")])
301
- for t in temp_group:
302
- groups.extend(t.split("/"))
316
+ subgroups = []
317
+
318
+ for gp in c12n:
319
+ # If there is a rel marking we know we have groups
320
+ if gp.startswith("REL "):
321
+ gp = gp.replace("REL TO ", "")
322
+ gp = gp.replace("REL ", "")
323
+ temp_group = set([x.strip() for x in gp.split(",")])
324
+ for t in temp_group:
325
+ groups.extend(t.split("/"))
326
+ else:
327
+ # if there is not a rel marking we either have a subgroup or a solitary_display_name
328
+ # alias for a group, which we will filter out later
329
+ subgroups.append(gp)
303
330
 
304
331
  for g in groups:
305
332
  if g in self.groups_map_lts:
@@ -309,33 +336,63 @@ class Classification(object):
309
336
  elif g in self.groups_aliases:
310
337
  for a in self.groups_aliases[g]:
311
338
  g1_set.add(a)
312
- elif g in self.subgroups_map_lts:
339
+ else:
340
+ others.add(g)
341
+
342
+ for g in subgroups:
343
+ if g in self.subgroups_map_lts:
313
344
  g2_set.add(self.subgroups_map_lts[g])
314
345
  elif g in self.subgroups_map_stl:
315
346
  g2_set.add(g)
316
347
  elif g in self.subgroups_aliases:
317
348
  for a in self.subgroups_aliases[g]:
318
349
  g2_set.add(a)
350
+ # Here is where we catch any solitary_display_name aliases for groups within the subgroup sections
351
+ elif g in self.groups_aliases:
352
+ # Check that this alias is actually a solitary name, don't
353
+ # let other aliases leak outside the REL marking
354
+ groups = self.groups_aliases[g]
355
+ if len(groups) > 1:
356
+ raise InvalidClassification(f"Unclear use of alias: {g}")
357
+ g1_set.add(groups[0])
319
358
  else:
320
- others.add(g)
359
+ raise InvalidClassification(f"Unknown Subgroup: {g}")
360
+
361
+ # If dynamic groups are active all remaining parts should be groups found under a
362
+ # REL TO marking that we can merge in with the other groups
363
+ if self.dynamic_groups and get_dynamic_groups:
364
+ g1_set.update(others)
365
+ others = set()
366
+
367
+ # Check if there are any required group assignments
368
+ for subgroup in g2_set:
369
+ required = self.params_map.get(subgroup, {}).get("require_group", None)
370
+ if required:
371
+ g1_set.add(required)
372
+
373
+ # Check if there are any forbidden group assignments
374
+ for subgroup in g2_set:
375
+ limited_to_group = self.params_map.get(subgroup, {}).get("limited_to_group", None)
376
+ if limited_to_group is not None:
377
+ if len(g1_set) > 1 or (len(g1_set) == 1 and g1_set != set([limited_to_group])):
378
+ raise InvalidClassification(
379
+ f"Subgroup {subgroup} is limited to group {limited_to_group} (found: {', '.join(g1_set)})"
380
+ )
321
381
 
322
- if self.dynamic_groups:
323
- for o in others:
324
- if (
325
- o not in self.access_req_map_lts
326
- and o not in self.access_req_map_stl
327
- and o not in self.access_req_aliases
328
- and o not in self.levels_map
329
- and o not in self.levels_map_lts
330
- and o not in self.levels_aliases
331
- ):
332
- g1_set.add(o)
382
+ # Do auto select
383
+ if auto_select and g1_set:
384
+ g1_set.update(self.groups_auto_select_short)
385
+ if auto_select and g2_set:
386
+ g2_set.update(self.subgroups_auto_select_short)
333
387
 
388
+ # Swap to long format if required
334
389
  if long_format:
335
- return sorted([self.groups_map_stl.get(r, r) for r in g1_set]), sorted(
336
- [self.subgroups_map_stl[r] for r in g2_set]
390
+ return (
391
+ sorted([self.groups_map_stl.get(r, r) for r in g1_set]),
392
+ sorted([self.subgroups_map_stl[r] for r in g2_set]),
393
+ list(others),
337
394
  )
338
- return sorted(list(g1_set)), sorted(list(g2_set))
395
+ return sorted(list(g1_set)), sorted(list(g2_set)), list(others)
339
396
 
340
397
  @staticmethod
341
398
  def _can_see_required(user_req: List, req: List) -> bool:
@@ -446,11 +503,22 @@ class Classification(object):
446
503
  return out
447
504
 
448
505
  def _get_classification_parts(
449
- self, c12n: str, long_format: bool = True
450
- ) -> Tuple[Union[Union[int, str], Any], List, List, List]:
451
- lvl_idx = self._get_c12n_level_index(c12n)
452
- req = self._get_c12n_required(c12n, long_format=long_format)
453
- groups, subgroups = self._get_c12n_groups(c12n, long_format=long_format)
506
+ self,
507
+ c12n: str,
508
+ long_format: bool = True,
509
+ get_dynamic_groups: bool = True,
510
+ auto_select: bool = False,
511
+ ignore_unused: bool = False,
512
+ ) -> Tuple[int, list[str], list[str], list[str]]:
513
+ lvl_idx, unused = self._get_c12n_level_index(c12n)
514
+ req, unused_parts = self._get_c12n_required(unused, long_format=long_format)
515
+
516
+ groups, subgroups, unused_parts = self._get_c12n_groups(
517
+ unused_parts, long_format=long_format, get_dynamic_groups=get_dynamic_groups, auto_select=auto_select
518
+ )
519
+
520
+ if unused_parts and not ignore_unused:
521
+ raise InvalidClassification(f"Unparsable classification parts: {', '.join(unused_parts)}")
454
522
 
455
523
  return lvl_idx, req, groups, subgroups
456
524
 
@@ -473,7 +541,7 @@ class Classification(object):
473
541
  # Public functions
474
542
  # ++++++++++++++++++++++++
475
543
  # noinspection PyUnusedLocal
476
- def list_all_classification_combinations(self, long_format: bool = True) -> Set:
544
+ def list_all_classification_combinations(self, long_format: bool = True, normalized: bool = False) -> Set:
477
545
  combinations = set()
478
546
 
479
547
  levels = self._list_items_and_aliases(self.original_definition["levels"], long_format=long_format)
@@ -529,7 +597,10 @@ class Classification(object):
529
597
 
530
598
  temp_combinations = copy(combinations)
531
599
  for p in itertools.product(temp_combinations, sgrp_cbs):
532
- if "//REL TO " in p[0]:
600
+ # A combo already has a group part when it contains "//REL TO " (explicit)
601
+ # or ends with a solitary display name like "//ANY" (after replacement).
602
+ has_group = "//REL TO " in p[0] or any(p[0].endswith(f"//{sol_name}") for sol_name in solitary_names)
603
+ if has_group:
533
604
  cl = "/".join(p)
534
605
 
535
606
  if cl.endswith("/"):
@@ -537,13 +608,25 @@ class Classification(object):
537
608
  else:
538
609
  combinations.add(cl)
539
610
  else:
540
- cl = "//REL TO ".join(p)
611
+ # No group present — subgroups are joined with "//" (not "//REL TO ")
612
+ # to match the format produced by _get_normalized_classification_text.
613
+ # "REL TO" is reserved for groups in _get_c12n_groups.
614
+ cl = "//".join(p)
541
615
 
542
- if cl.endswith("//REL TO "):
543
- combinations.add(cl[:-9])
616
+ if cl.endswith("//"):
617
+ combinations.add(cl[:-2])
544
618
  else:
545
619
  combinations.add(cl)
546
620
 
621
+ if normalized:
622
+ good = []
623
+ for x in combinations:
624
+ try:
625
+ good.append(self.normalize_classification(x, long_format=long_format))
626
+ except InvalidClassification:
627
+ pass
628
+ return set(good)
629
+
547
630
  return combinations
548
631
 
549
632
  # noinspection PyUnusedLocal
@@ -581,7 +664,8 @@ class Classification(object):
581
664
  return out
582
665
 
583
666
  def get_access_control_parts(self, c12n: str, user_classification: bool = False) -> Dict:
584
- """Returns a dictionary containing the different access parameters Lucene needs to build it's queries
667
+ """
668
+ Returns a dictionary containing the different access parameters Lucene needs to build it's queries
585
669
 
586
670
  Args:
587
671
  c12n: The classification to get the parts from
@@ -591,12 +675,8 @@ class Classification(object):
591
675
  c12n = self.UNRESTRICTED
592
676
 
593
677
  try:
594
- # Normalize the classification before gathering the parts
595
- c12n = self.normalize_classification(c12n, skip_auto_select=user_classification)
596
-
597
- access_lvl = self._get_c12n_level_index(c12n)
598
- access_req = self._get_c12n_required(c12n, long_format=False)
599
- access_grp1, access_grp2 = self._get_c12n_groups(c12n, long_format=False)
678
+ parts = self._get_classification_parts(c12n, long_format=False, auto_select=not user_classification)
679
+ access_lvl, access_req, access_grp1, access_grp2 = parts
600
680
 
601
681
  return {
602
682
  "__access_lvl__": access_lvl,
@@ -679,7 +759,8 @@ class Classification(object):
679
759
  )
680
760
 
681
761
  def is_accessible(self, user_c12n: str, c12n: str, ignore_invalid: bool = False) -> bool:
682
- """Given a user classification, check if a user is allow to see a certain classification
762
+ """
763
+ Given a user classification, check if a user is allow to see a certain classification
683
764
 
684
765
  Args:
685
766
  user_c12n: Maximum classification for the user
@@ -698,16 +779,10 @@ class Classification(object):
698
779
  return True
699
780
 
700
781
  try:
701
- # Normalize classifications before comparing them
702
- user_c12n = self.normalize_classification(user_c12n, skip_auto_select=True)
703
- c12n = self.normalize_classification(c12n, skip_auto_select=True)
782
+ user_lvl, user_req, user_groups, user_subgroups = self._get_classification_parts(user_c12n)
783
+ lvl, req, groups, subgroups = self._get_classification_parts(c12n)
704
784
 
705
- user_req = self._get_c12n_required(user_c12n)
706
- user_groups, user_subgroups = self._get_c12n_groups(user_c12n)
707
- req = self._get_c12n_required(c12n)
708
- groups, subgroups = self._get_c12n_groups(c12n)
709
-
710
- if self._get_c12n_level_index(user_c12n) >= self._get_c12n_level_index(c12n):
785
+ if int(user_lvl) >= int(lvl):
711
786
  if not self._can_see_required(user_req, req):
712
787
  return False
713
788
  if not self._can_see_groups(user_groups, groups):
@@ -812,7 +887,7 @@ class Classification(object):
812
887
 
813
888
  return True
814
889
 
815
- def max_classification(self, c12n_1: str, c12n_2: str, long_format: bool = True) -> str:
890
+ def max_classification(self, c12n_1: str | None, c12n_2: str | None, long_format: bool = True) -> str:
816
891
  """Mixes to classification and returns to most restrictive form for them
817
892
 
818
893
  Args:
@@ -833,9 +908,10 @@ class Classification(object):
833
908
  c12n_2 = self.normalize_classification(c12n_2)
834
909
 
835
910
  if c12n_1 is None:
836
- return c12n_2
911
+ return c12n_2 if c12n_2 else self.UNRESTRICTED
912
+
837
913
  if c12n_2 is None:
838
- return c12n_1
914
+ return c12n_1 if c12n_1 else self.UNRESTRICTED
839
915
 
840
916
  lvl_idx_1, req_1, groups_1, subgroups_1 = self._get_classification_parts(c12n_1, long_format=long_format)
841
917
  lvl_idx_2, req_2, groups_2, subgroups_2 = self._get_classification_parts(c12n_2, long_format=long_format)
@@ -899,8 +975,16 @@ class Classification(object):
899
975
  long_format=long_format, # type: ignore
900
976
  )
901
977
 
902
- def normalize_classification(self, c12n: str, long_format: bool = True, skip_auto_select: bool = False) -> str:
903
- """Normalize a given classification by applying the rules defined in the classification definition.
978
+ def normalize_classification(
979
+ self,
980
+ c12n: str,
981
+ long_format: bool = True,
982
+ skip_auto_select: bool = False,
983
+ get_dynamic_groups: bool = True,
984
+ ignore_unused: bool = False,
985
+ ) -> str:
986
+ """
987
+ Normalize a given classification by applying the rules defined in the classification definition.
904
988
  This function will remove any invalid parts and add missing parts to the classification.
905
989
  It will also ensure that the display of the classification is always done the same way
906
990
 
@@ -916,19 +1000,16 @@ class Classification(object):
916
1000
  return self.UNRESTRICTED
917
1001
 
918
1002
  # Has the classification has already been normalized before?
919
- if long_format and c12n in self._classification_cache:
1003
+ if long_format and c12n in self._classification_cache and get_dynamic_groups:
920
1004
  return c12n
921
- if not long_format and c12n in self._classification_cache_short:
1005
+ if not long_format and c12n in self._classification_cache_short and get_dynamic_groups:
922
1006
  return c12n
923
1007
 
924
- lvl_idx, req, groups, subgroups = self._get_classification_parts(c12n, long_format=long_format)
1008
+ lvl_idx, req, groups, subgroups = self._get_classification_parts(
1009
+ c12n, long_format=long_format, get_dynamic_groups=get_dynamic_groups, ignore_unused=ignore_unused
1010
+ )
925
1011
  new_c12n = self._get_normalized_classification_text(
926
- lvl_idx, # type: ignore
927
- req,
928
- groups,
929
- subgroups,
930
- long_format=long_format,
931
- skip_auto_select=skip_auto_select,
1012
+ lvl_idx, req, groups, subgroups, long_format=long_format, skip_auto_select=skip_auto_select
932
1013
  )
933
1014
  if long_format:
934
1015
  self._classification_cache.add(new_c12n)
@@ -937,7 +1018,7 @@ class Classification(object):
937
1018
 
938
1019
  return new_c12n
939
1020
 
940
- def build_user_classification(self, c12n_1: str | None, c12n_2: str | None, long_format: bool = True) -> str | None:
1021
+ def build_user_classification(self, c12n_1: str, c12n_2: str, long_format: bool = True) -> str:
941
1022
  """Mixes to classification and return the classification marking that would give access to the most data
942
1023
 
943
1024
  Args:
@@ -959,7 +1040,6 @@ class Classification(object):
959
1040
 
960
1041
  if c12n_1 is None:
961
1042
  return c12n_2
962
-
963
1043
  if c12n_2 is None:
964
1044
  return c12n_1
965
1045
 
howler/config.py CHANGED
@@ -10,7 +10,7 @@ from howler.remote.datatypes.user_quota_tracker import UserQuotaTracker
10
10
  #################################################################
11
11
  # Configuration
12
12
 
13
- CLASSIFICATION = loader.get_classification()
13
+ CLASSIFICATION = loader.get_classification(os.getenv("HWL_CLASSIFICATION_PATH", None))
14
14
 
15
15
  AUDIT = config.ui.audit
16
16
 
howler/helper/azure.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import requests
2
2
 
3
- from howler.common.exceptions import HowlerException
3
+ from howler.common.exceptions import HowlerAttributeError, HowlerException
4
4
  from howler.common.logging import get_logger
5
5
  from howler.config import config
6
6
  from howler.utils.str_utils import default_string_value
@@ -20,7 +20,12 @@ def azure_obo(token: str) -> str:
20
20
  Returns:
21
21
  str: The new access token with updated privileges
22
22
  """
23
- azure_provider_config = config.auth.oauth.providers["azure"]
23
+ if "azure" in config.auth.oauth.providers:
24
+ azure_provider_config = config.auth.oauth.providers["azure"]
25
+ elif "entraid" in config.auth.oauth.providers:
26
+ azure_provider_config = config.auth.oauth.providers["entraid"]
27
+ else:
28
+ raise HowlerAttributeError("No azure/entraid-based provider configured!")
24
29
 
25
30
  logger.debug("OBOing to MS Graph")
26
31
  # Azure is a special case here, as we need to OBO to MS Graph
howler/helper/oauth.py CHANGED
@@ -60,7 +60,7 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
60
60
  uname = profile.get("uname", profile.get("preferred_username", email_adr))
61
61
 
62
62
  # Did we default to email?
63
- if email_adr is not None and uname is not None and uname.lower() == email_adr.lower():
63
+ if uname is not None and email_adr is not None and uname.lower() == email_adr.lower():
64
64
  # 1. Use provided regex matcher
65
65
  if provider_config.uid_regex:
66
66
  match = re.match(provider_config.uid_regex, uname)
@@ -93,6 +93,8 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
93
93
  # Compute access, roles and classification using auto_properties
94
94
  access = True
95
95
  roles = ["user"]
96
+ groups: list[str] = profile.get("groups", [])
97
+ assignments = []
96
98
  classification = CLASSIFICATION_ENGINE.UNRESTRICTED
97
99
  if provider_config.auto_properties:
98
100
  for auto_prop in provider_config.auto_properties:
@@ -129,18 +131,33 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
129
131
  classification = CLASSIFICATION_ENGINE.build_user_classification(
130
132
  classification, auto_prop.value
131
133
  )
134
+ logger.debug("Classification: %s", classification)
132
135
  break
133
136
 
134
- # Infer roles from groups
135
- if profile.get("groups") and provider_config.role_map:
137
+ # Append groups from matching patterns
138
+ elif auto_prop.type == "group":
139
+ if re.match(auto_prop.pattern, value):
140
+ groups.append(auto_prop.value)
141
+ break
142
+
143
+ # Append assignments from matching patterns
144
+ elif auto_prop.type == "assignment":
145
+ if re.match(auto_prop.pattern, value):
146
+ assignments.append(auto_prop.value)
147
+ break
148
+
149
+ # Infer roles from groups (legacy)
150
+ if groups and provider_config.role_map:
136
151
  for user_type in USER_TYPES:
137
152
  if (
138
153
  user_type in provider_config.role_map
139
- and provider_config.role_map[user_type] in profile.get("groups", [])
154
+ and provider_config.role_map[user_type] in groups
140
155
  and user_type not in roles
141
156
  ):
142
157
  roles.append(user_type)
143
158
 
159
+ # TODO: re-export assignments once they're actually used.
160
+ # This may need a refactor once the tags stuff is figured out
144
161
  return dict(
145
162
  access=access,
146
163
  type=roles,
@@ -150,7 +167,7 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
150
167
  email=email_adr,
151
168
  password="__NO_PASSWORD__", # noqa: S106
152
169
  avatar=profile.get("picture", provider_config.picture_url or alternate),
153
- groups=profile.get("groups", []),
170
+ groups=groups,
154
171
  )
155
172
 
156
173
 
@@ -166,9 +183,9 @@ def fetch_avatar( # noqa: C901
166
183
  # Generic picture url endpoint, i.e. MS Graph
167
184
  if url == provider_config.picture_url:
168
185
  headers = {}
169
-
170
186
  token: str | None = None
171
- if oauth_provider == "azure":
187
+
188
+ if oauth_provider in ["entraid", "azure"]:
172
189
  if not access_token:
173
190
  raise HowlerValueError("An azure access token is necessary to retrieve the profile picture") # noqa: TRY301
174
191
 
@@ -206,13 +223,13 @@ def fetch_avatar( # noqa: C901
206
223
  return None
207
224
 
208
225
 
209
- def fetch_groups(token: str):
210
- """Fetch a user's groups form an external endpoint"""
226
+ def fetch_groups(token: str): # noqa: C901
227
+ """Fetch a user's groups from an external endpoint"""
211
228
  oauth_provider = jwt_service.get_provider(token)
212
229
  oauth_provider_config = config.auth.oauth.providers[oauth_provider]
213
230
 
214
231
  if oauth_provider_config.groups_url:
215
- if oauth_provider == "azure":
232
+ if oauth_provider in ["entraid", "azure"]:
216
233
  try:
217
234
  token = azure_obo(token)
218
235
  except HowlerException:
@@ -232,13 +249,20 @@ def fetch_groups(token: str):
232
249
  result = result[part]
233
250
 
234
251
  detailed_group_data = []
252
+
235
253
  for group in result:
236
- detailed_group_data.append(
237
- {
238
- "id": group.get("id", None),
239
- "name": group.get("name", group.get("displayName", group.get("id", None))),
240
- }
241
- )
254
+ # Only process groups that are groups (not directory roles)
255
+ if group.get("@odata.type") == "#microsoft.graph.group":
256
+ group_id = group.get("id")
257
+ display_name = group.get("displayName")
258
+ if display_name: # Only add if display name exists
259
+ detailed_group_data.append(
260
+ {
261
+ "id": group_id,
262
+ "name": display_name,
263
+ }
264
+ )
265
+ logger.debug("Added group: %s (%s)", display_name, group_id)
242
266
 
243
267
  return sorted(detailed_group_data, key=lambda g: g.get("name", "").lower())
244
268
 
howler/helper/search.py CHANGED
@@ -5,7 +5,7 @@ from howler.datastore.collection import ESCollection
5
5
  from howler.odm.models.user import User
6
6
 
7
7
  # List of indices where queries are protected with classification access control
8
- ACCESS_CONTROLLED_INDICES: dict[str, ESCollection] = {}
8
+ ACCESS_CONTROLLED_INDICES: set[str] = {"hit"}
9
9
 
10
10
  ADMIN_INDEX_MAP: dict[str, Callable[[], ESCollection]] = {}
11
11
 
@@ -176,7 +176,7 @@ class OAuthAutoProperty(BaseModel):
176
176
 
177
177
  field: str = Field(description="Field to apply `pattern` to")
178
178
  pattern: str = Field(description="Regex pattern for auto-prop assignment")
179
- type: Literal["access", "classification", "role"] = Field(
179
+ type: Literal["access", "classification", "role", "group", "assignment"] = Field(
180
180
  description="Type of property assignment on pattern match",
181
181
  )
182
182
  value: str = Field(description="Assigned property value")
howler/odm/models/hit.py CHANGED
@@ -3,6 +3,7 @@ from typing import Optional
3
3
 
4
4
  from howler import odm
5
5
  from howler.common.logging import get_logger
6
+ from howler.config import CLASSIFICATION
6
7
  from howler.odm.models.assemblyline import AssemblyLine
7
8
  from howler.odm.models.aws import AWS
8
9
  from howler.odm.models.azure import Azure
@@ -67,6 +68,12 @@ class Hit(odm.Model):
67
68
  description="Custom key/value pairs.",
68
69
  reference="https://www.elastic.co/guide/en/ecs/8.5/ecs-base.html",
69
70
  )
71
+ classification: str = odm.Classification(
72
+ is_user_classification=False,
73
+ copyto="__text__",
74
+ default=CLASSIFICATION.UNRESTRICTED,
75
+ description="Maximum classification for the hit",
76
+ )
70
77
  tags: list[str] = odm.List(
71
78
  odm.Keyword(),
72
79
  default=[],
howler/odm/models/user.py CHANGED
@@ -60,6 +60,7 @@ class User(odm.Model):
60
60
  is_active: bool = odm.Boolean(default=True, description="Is the user active?")
61
61
  name: str = odm.Keyword(copyto="__text__", description="Full name of the user")
62
62
  password: str = odm.Keyword(index=False, store=False, description="BCrypt hash of the user's password")
63
+ access_control: str = odm.Keyword(index=False, store=False, optional=True, description="Access control filter")
63
64
  type: list[str] = odm.List(
64
65
  odm.Enum(values=loader.USER_TYPES),
65
66
  default=["user", "automation_basic"],
howler/odm/random_data.py CHANGED
@@ -45,7 +45,7 @@ from howler.odm.models.user import User
45
45
  from howler.odm.models.view import View
46
46
  from howler.odm.randomizer import get_random_string, get_random_user, get_random_word, random_model_obj
47
47
  from howler.security.utils import get_password_hash
48
- from howler.services import analytic_service
48
+ from howler.services import analytic_service, user_service
49
49
 
50
50
  classification = loader.get_classification()
51
51
 
@@ -155,6 +155,7 @@ def create_users(ds):
155
155
  {
156
156
  "name": "Dwight Schrute",
157
157
  "email": "user@howler.cyber.gc.ca",
158
+ "classification": classification.RESTRICTED,
158
159
  "apikeys": {
159
160
  "devkey": {"acl": ["R", "W"], "password": user_hash},
160
161
  "impersonate_admin": {
@@ -174,6 +175,10 @@ def create_users(ds):
174
175
  }
175
176
  )
176
177
 
178
+ c12n = user_service.get_dynamic_classification(user_data.as_primitives())
179
+ if c12n:
180
+ user_data.classification = c12n
181
+
177
182
  user_view = run_modifications("view", user_view)
178
183
  user_data = run_modifications("user", user_data)
179
184
 
@@ -215,6 +220,7 @@ def create_users(ds):
215
220
  "password": huey_hash,
216
221
  },
217
222
  },
223
+ "classification": classification.UNRESTRICTED,
218
224
  "password": huey_hash,
219
225
  "uname": "huey",
220
226
  "favourite_views": [huey_view.view_id],
@@ -249,6 +255,7 @@ def create_users(ds):
249
255
  "apikeys": {},
250
256
  "type": ["admin", "user"],
251
257
  "groups": ["group1", "group2"],
258
+ "classification": classification.UNRESTRICTED,
252
259
  "password": get_password_hash(shawnh_pass),
253
260
  "uname": "shawn-h",
254
261
  "favourite_views": [shawnh_view.view_id],
@@ -279,6 +286,7 @@ def create_users(ds):
279
286
  "apikeys": {},
280
287
  "type": ["admin", "user"],
281
288
  "groups": ["group1", "group2"],
289
+ "classification": classification.RESTRICTED,
282
290
  "password": get_password_hash(goose_pass),
283
291
  "uname": "goose",
284
292
  "favourite_views": [goose_view.view_id],
@@ -527,6 +535,10 @@ def create_hits(ds: HowlerDatastore, hit_count: int = 200):
527
535
  for hit_idx in range(hit_count):
528
536
  hit = generate_useful_hit(lookups, [user.uname for user in users], prune_hit=False)
529
537
 
538
+ # Ensure the first 20 hits have unrestricted classification for test access
539
+ if hit_idx < 20:
540
+ hit.classification = classification.UNRESTRICTED
541
+
530
542
  if hit_idx + 1 == hit_count:
531
543
  hit.howler.analytic = "SecretAnalytic"
532
544
  hit.howler.detection = None
@@ -588,6 +600,7 @@ def create_bundles(ds: HowlerDatastore):
588
600
  for i in range(3):
589
601
  bundle_hit: Hit = generate_useful_hit(lookups, users)
590
602
  bundle_hit.howler.is_bundle = True
603
+ bundle_hit.classification = classification.UNRESTRICTED
591
604
 
592
605
  for hit in ds.hit.search("howler.is_bundle:false", rows=randint(3, 10), offset=(i * 2))["items"]:
593
606
  if hit.howler.id not in hits:
howler/odm/randomizer.py CHANGED
@@ -454,7 +454,7 @@ def random_data_for_field(field: _Field, name: str, minimal: bool = False) -> _A
454
454
  return random.choice([True, False])
455
455
  elif isinstance(field, Classification):
456
456
  if field.engine.enforce:
457
- possible_classifications = list(field.engine._classification_cache)
457
+ possible_classifications = list(field.engine.list_all_classification_combinations(normalized=True))
458
458
  possible_classifications.extend([field.engine.UNRESTRICTED, field.engine.RESTRICTED])
459
459
  else:
460
460
  possible_classifications = [field.engine.UNRESTRICTED]
@@ -490,11 +490,13 @@ def random_data_for_field(field: _Field, name: str, minimal: bool = False) -> _A
490
490
  elif isinstance(field, Date):
491
491
  return get_random_iso_date()
492
492
  elif isinstance(field, Integer):
493
- return random.randint(128, 4096)
493
+ return random.randint(-2_147_483_648, 2_147_483_647)
494
494
  elif isinstance(field, Long):
495
- return random.randint(1, 223372036854775807)
495
+ # Generate a random 64-bit signed integer
496
+ return random.randint(-9_223_372_036_854_775_808, 9_223_372_036_854_775_807)
496
497
  elif isinstance(field, Float):
497
- return random.randint(12800, 409600) / 100.0
498
+ # Generate a random float in a reasonable range
499
+ return random.uniform(-1e6, 1e6)
498
500
  elif isinstance(field, MD5):
499
501
  return get_random_hash(32)
500
502
  elif isinstance(field, SHA1):
@@ -112,8 +112,8 @@ def get_audience(oauth_provider: str) -> str:
112
112
  elif provider_data.client_id:
113
113
  audience = provider_data.client_id
114
114
 
115
- if oauth_provider == "azure" and f"{audience}/.default" not in provider_data.scope:
116
- raise HowlerValueError("Azure scope must contain the <client_id>/.default claim!")
115
+ if oauth_provider in ["entraid", "azure"] and f"{audience}/.default" not in provider_data.scope:
116
+ raise HowlerValueError("Azure/Entra ID scope must contain the <client_id>/.default claim!")
117
117
 
118
118
  return audience
119
119
 
@@ -176,7 +176,7 @@ def parse_user_data( # noqa: C901
176
176
  username = user_data["uname"]
177
177
 
178
178
  # Add add dynamic classification group
179
- user_data["classification"] = get_dynamic_classification(user_data["classification"], user_data["email"])
179
+ user_data["classification"] = get_dynamic_classification(user_data)
180
180
 
181
181
  # Make sure the user exists in howler and is in sync
182
182
  if (not current_user and oauth_provider_config.auto_create) or (
@@ -193,20 +193,26 @@ def parse_user_data( # noqa: C901
193
193
  avatar = current_user.pop("avatar", None)
194
194
 
195
195
  # Save updated user if there are changes to sync or it doesn't exist
196
+ log_id: str | None = user_id if not isinstance(user_id, list) else user_id[0]
196
197
  if old_user != current_user:
197
- if user_id:
198
- logger.info("Updating %s with new data", user_id if not isinstance(user_id, list) else user_id[0])
198
+ if log_id:
199
+ logger.info("Updating %s with new data", log_id)
200
+ current_user["id"] = log_id
199
201
  else:
200
202
  logger.info("Creating new user %s", username)
201
203
 
202
- if user_id:
203
- current_user["id"] = user_id
204
-
205
204
  if avatar:
206
205
  current_user["avatar"] = avatar
207
206
 
207
+ add_access_control(current_user)
208
+ storage.user.save(username, current_user)
209
+ # Ensure access_control is always present, even if user data hasn't changed
210
+ elif "access_control" not in current_user:
211
+ logger.info("Adding access control for user %s", log_id or username)
212
+ add_access_control(current_user)
208
213
  storage.user.save(username, current_user)
209
- storage.user.commit()
214
+ else:
215
+ logger.debug("User is up to date!")
210
216
 
211
217
  if not skip_setup:
212
218
  if avatar:
@@ -336,18 +342,36 @@ def save_user_account(username: str, data: dict[str, Any], user: dict[str, Any])
336
342
  return storage.user.save(username, data)
337
343
 
338
344
 
339
- def get_dynamic_classification(current_c12n: str | None, email: str) -> str | None:
345
+ def get_dynamic_classification(user_info: dict[str, Any]) -> str | None:
340
346
  """Get the classification of the user
341
347
 
342
348
  Args:
343
349
  current_c12n (str): The current classification of the user
344
- email (str): The user's email
350
+ user_info (dict): The user definition
345
351
 
346
352
  Returns:
347
- str: The classification
353
+ str: The normalized classification with dynamic groups applied
348
354
  """
349
- if CLASSIFICATION.dynamic_groups and email:
350
- dyn_group = email.upper().split("@")[1]
351
- return CLASSIFICATION.build_user_classification(current_c12n, f"{CLASSIFICATION.UNRESTRICTED}//{dyn_group}")
355
+ new_c12n = CLASSIFICATION.normalize_classification(
356
+ user_info.get("classification", CLASSIFICATION.UNRESTRICTED),
357
+ skip_auto_select=True,
358
+ get_dynamic_groups=False,
359
+ ignore_unused=True,
360
+ )
361
+
362
+ if CLASSIFICATION.dynamic_groups:
363
+ email = user_info.get("email", None)
364
+ groups = user_info.get("groups", [])
365
+
366
+ if CLASSIFICATION.dynamic_groups_type in ["email", "all"] and email:
367
+ dyn_group = email.upper().split("@")[1]
368
+ new_c12n = CLASSIFICATION.build_user_classification(
369
+ new_c12n, f"{CLASSIFICATION.UNRESTRICTED}//REL {dyn_group}"
370
+ )
371
+
372
+ if CLASSIFICATION.dynamic_groups_type in ["group", "all"] and groups:
373
+ new_c12n = CLASSIFICATION.build_user_classification(
374
+ new_c12n, f"{CLASSIFICATION.UNRESTRICTED}//REL {', '.join(groups)}"
375
+ )
352
376
 
353
- return current_c12n
377
+ return new_c12n
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: howler-api
3
- Version: 3.3.0.dev767
3
+ Version: 3.3.0.dev768
4
4
  Summary: Howler - API server
5
5
  License: MIT
6
6
  Keywords: howler,alerting,gc,canada,cse-cst,cse,cst,cyber,cccs
@@ -27,14 +27,14 @@ howler/api/v1/overview.py,sha256=hHP9B6mWpBSnOM_ospSusFsruiWX37eTG6ecNtd0-j4,501
27
27
  howler/api/v1/search.py,sha256=gdKd_Goo2bFWB4nwvLT99dxnSrTRnZN0GHw_bCY54Po,27815
28
28
  howler/api/v1/template.py,sha256=q4QSPPM1YGiuvufO14IUKWENTrFRZBpvbXX88Oa0fm8,5785
29
29
  howler/api/v1/tool.py,sha256=Y7-iF_dfZxlEUjfOvAK4yY4WQWuXNP56Q-7ie5Nt0oI,6752
30
- howler/api/v1/user.py,sha256=_PJNiTLGRQUNwKghXi3-lwOt83AsJ_uFXZWwwMnHCmQ,13924
30
+ howler/api/v1/user.py,sha256=fROPhzcrW5KXczt7hzQMW3031JA0BxtWVFi2C7vibT0,13816
31
31
  howler/api/v1/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  howler/api/v1/utils/etag.py,sha256=IGB4WUkecHbBt0KKLQFCxCn0mN3T_aSPG0y45l_zT9I,3431
33
33
  howler/api/v1/view.py,sha256=gneyowjUIWg2H9QUdIWuLe3J2p2kCWSkNDh0VdpE4DE,8223
34
34
  howler/app.py,sha256=h8ZlGjiZEB7w_GhKQz6dXbZ5rnxYxpTJbNXUBbBAtsU,6799
35
35
  howler/common/README.md,sha256=8uPpDqh14Ne7Rtn9BwPwE-JpuFkauVsTr2s0s4YG5Bg,5637
36
36
  howler/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
- howler/common/classification.py,sha256=b0Qd2zaG9S_gPrrfC7HOpdxqUAe6ICObAVP9aSTPr_Y,39355
37
+ howler/common/classification.py,sha256=oOpyfZh4SudrCcLjBpJIOi3Ye_iy5Kc1Nu27KScp9og,43057
38
38
  howler/common/classification.yml,sha256=3VBqzKYkiqz8bCsSuqNvqxeqUPN1NmXBKHusGFO3hbg,4092
39
39
  howler/common/exceptions.py,sha256=An8MW-qB8g7EYLTxddnoIiyxNDQAqU9AmH8MrDFfnwQ,5804
40
40
  howler/common/loader.py,sha256=VgoNwf8G95t4MIe9TBYhnSDmU1XZlxsmeNEP1wFcFrg,5455
@@ -45,7 +45,7 @@ howler/common/net.py,sha256=H4KrP-aUeYVxjLYmC3yNTSriQEmqC__Fzho7p86rAlI,1795
45
45
  howler/common/net_static.py,sha256=q5ccJRFft4flMwhtQSN14M8Fn_GWxQ6yLK4mzSqCW6g,20589
46
46
  howler/common/random_user.py,sha256=0NEJzLBz08dcSCk7-M6HQpC89qc4QUTKa_zC84hfds4,4920
47
47
  howler/common/swagger.py,sha256=XqFVxgCQg_HJrLdl6b37alwyanOWmcAU1bbVi6eDQrA,3752
48
- howler/config.py,sha256=amwf4FXJ1ZKeooHqPlC_F691tg6Yaj4osKn_ExiUSS0,2128
48
+ howler/config.py,sha256=aMWL40YnN29moAIjm3NXlPHPQrIeagRd68sjuuDwK2I,2170
49
49
  howler/cronjobs/__init__.py,sha256=GEhNsxPGATumlroMa-g6z5Dt9yy0QT93s3uuNC-GwIM,909
50
50
  howler/cronjobs/retention.py,sha256=qpUT7A2UoIJIyHZ_zpcgOe1o3do6xLRsV-uUxEJ5m94,1778
51
51
  howler/cronjobs/rules.py,sha256=-yAjGTyYI7jeQ9wigjoupRMq3b6DakU0EW0XCS8xowQ,10511
@@ -76,11 +76,11 @@ howler/external/wipe_databases.py,sha256=CO_mUdezp24h6xz6Di_K5-Mid61d9EVcBO4ap5q
76
76
  howler/gunicorn_config.py,sha256=0X7DPFcVDu3qAgMbNsdYGqCuvQGn5ZNx_eapOMyuXuI,750
77
77
  howler/healthz.py,sha256=nvb8MBBERYIkA_UxxLIyNEQazYOnPCcm0sH0Jm5nF0k,769
78
78
  howler/helper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
- howler/helper/azure.py,sha256=Sbm4kpCaU1IcQQS9gtoJEF8myGnqhlAP4Lb_zJSCFvo,1543
79
+ howler/helper/azure.py,sha256=evI413uz-PdI6C5HIHh8LPRx83mTO8y6nad5ed_qJ5Y,1830
80
80
  howler/helper/discover.py,sha256=j1nPHOPZ9CwvcfLT2xd-uvvpEex-nhPscl9aZwutwDk,2221
81
81
  howler/helper/hit.py,sha256=Us2B4Dngu_ghWCZ40o7mZ0iLKKDr2onV6oB2OLuE5Fc,7863
82
- howler/helper/oauth.py,sha256=aTzmcFJXsPDwxAP_J4wUpb8K7Xe1IZuiKG1iTHOrzpM,9549
83
- howler/helper/search.py,sha256=cU9bDPQv2LIRNQ2j-8rAqePCYq9CrgOtS76PrziSgG8,2892
82
+ howler/helper/oauth.py,sha256=08yApXF9l-mCVFUodmcZDDj6Y0B4tFge10OrDYqnQmg,10725
83
+ howler/helper/search.py,sha256=XQ_6ScHsmzfqXnQ3Rt-j9ADmddaSVsAcG6348L-WtPY,2882
84
84
  howler/helper/workflow.py,sha256=ATvEv5CI7Bm2bTEy0U0EPEdz6-fZfyqIkc-gZCYmRY8,4803
85
85
  howler/helper/ws.py,sha256=im7pFQJDmGs3EyaBviGD1csYmTTRwLx8Gfih6-MlPhs,15911
86
86
  howler/odm/README.md,sha256=Ihc_DyjVQlLaIOEbPoQNPkum9Ecn8kn37-PMFQsX77s,5645
@@ -97,7 +97,7 @@ howler/odm/models/aws.py,sha256=pJVadJqubdgT27riCfp7bEKVP4XsMZB0ZUnKAbmCMd0,895
97
97
  howler/odm/models/azure.py,sha256=o7MZMMo9jh1SB8xXCajl_YSKP2nnnWsjx_DPT6LnQKg,710
98
98
  howler/odm/models/cbs.py,sha256=onUiJOGUxK3iy_-4XkGGwHxFiFq9Td_p59Kum4XaR-w,1366
99
99
  howler/odm/models/clue.py,sha256=TNJV1t9bEhRTcJuSC2qNKQ-XpwFqb5iQstRpvHOrTHE,636
100
- howler/odm/models/config.py,sha256=jMmdr12dtVNdpZNKvKUOJvnWuUKrqwewOL-kEMXCmJY,22581
100
+ howler/odm/models/config.py,sha256=cEFdR8Tzl0VUwRTj-zJ8ARBsRAcnHarQcAiggmpoz0E,22604
101
101
  howler/odm/models/dossier.py,sha256=Ob2qROrG2-DYzmVo2XVe4NJ8HjWGCoRAu2gPo6p9XGU,1244
102
102
  howler/odm/models/ecs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
103
103
  howler/odm/models/ecs/agent.py,sha256=idSooyFCLuQAB7_RyEWTYW4-x9w5a3wpy2ct_-EDRQs,713
@@ -138,17 +138,17 @@ howler/odm/models/ecs/user.py,sha256=A1KoIZk5LLPR-boE3O9LEbMRDBbDcrXhdXjlFF1exYU
138
138
  howler/odm/models/ecs/user_agent.py,sha256=LM4ZjbdEkkF02zZu6Pg7j8JLuAyfliTC6PVAEw9W2S4,845
139
139
  howler/odm/models/ecs/vulnerability.py,sha256=iX2TUmCZTJjwyMcOmxiHg-pfoEwX8Wx-h8cxo5QsJmo,1553
140
140
  howler/odm/models/gcp.py,sha256=FLwaQVcfFeE5AbIhEB_Ge2UL-HjPdldY04Nrm9Mq7kk,675
141
- howler/odm/models/hit.py,sha256=kgzk3RzgKGvHdtNG0iMRxCtLPD73-BFFU9A_IxJcOHI,13396
141
+ howler/odm/models/hit.py,sha256=Gxr_afLleKzK0dM0VfgtftYTU5zo-egEggQgk0V_tX4,13657
142
142
  howler/odm/models/howler_data.py,sha256=7anqSVFyvecqTlmvg1_ULGgJmWii4AanTanbbqtMUqo,12515
143
143
  howler/odm/models/lead.py,sha256=lqapGWZ4u22Asib48o7wAzramnFY9EkRybmb4olsyrA,904
144
144
  howler/odm/models/localized_label.py,sha256=G7gfQ1cngiI4KprqldHWE1KHkAgK4AG_JsfHxVRdsRs,361
145
145
  howler/odm/models/overview.py,sha256=kvZcMYPDlkJEGa0L1jq9pG0RFjLOVudC64-2GTWVu2w,684
146
146
  howler/odm/models/pivot.py,sha256=ZewcGh91xbE64snZ5Ahz2So1onAqO8U9H4CFe6jBOXs,1405
147
147
  howler/odm/models/template.py,sha256=-Tqq_36qD_3nQ4jv13OPeH_EyyERKhc55wT03CU-Kbk,979
148
- howler/odm/models/user.py,sha256=XUh627AghESPkjCWXFTorAVRxZA1BKdAQik5HP7Ow48,3272
148
+ howler/odm/models/user.py,sha256=T1VTbUfRHU4g48iccW3TS7lWSgGXVfd1CuSx-b3TDjE,3388
149
149
  howler/odm/models/view.py,sha256=DYFrYcx4NT-Z_0GUg_5qjRRLwrMMFK78NQ-20iuFx8o,1323
150
- howler/odm/random_data.py,sha256=tAycwRyyGuY6RyqpyWirx1_SLmJFnqXg-CkFT-9TeO4,28877
151
- howler/odm/randomizer.py,sha256=B4fMtCtdO4Me2omTaM2xTcin8lp4ZiJVuwgq81LfqJE,23692
150
+ howler/odm/random_data.py,sha256=96HRJu1O47hDvZ3pA55UPN6ckPh458oxrxQrmupmJng,29490
151
+ howler/odm/randomizer.py,sha256=2gBwQA6karZQhX82cz0oBhgCXvmVlFXhdKXGdjoAjLM,23870
152
152
  howler/patched.py,sha256=Br4BGU5raaqjSMDLD7ogb5A8Yn0dzecouh6uWVV2jlQ,77
153
153
  howler/plugins/__init__.py,sha256=P5P-t4KgIInOzp4NmIturNIhUbb3jPO81n55Q_b_gM0,841
154
154
  howler/plugins/config.py,sha256=75sGAPQPU9_dGkJJ8_Q1H1qIYEhu9iv8c0_PDgAJDAs,4802
@@ -177,12 +177,12 @@ howler/services/config_service.py,sha256=tmSXHKMqJHT3ZoCb250Ad5l32Gh3MiWApIRSI8b
177
177
  howler/services/dossier_service.py,sha256=nn4lGJ8caJVBYQyp_sViVmMm6m0MetY0Wml5ITaZhb4,10559
178
178
  howler/services/event_service.py,sha256=4Mf2n02Rj2cfirwj_dIWkFGXzw_nrW8kfveAT-Kwx0M,2985
179
179
  howler/services/hit_service.py,sha256=jeqWfDvzNIvR0SxhZeCfdeVvA3zd0pBI74YE8hTgu98,34716
180
- howler/services/jwt_service.py,sha256=g0Q2pkENSeWxDEOgg9Kdvare2-HshDtD4UdxARLfGC0,5579
180
+ howler/services/jwt_service.py,sha256=2ULBu1GqosowIgtMpqgn0imRsOGfwFOioH-uVOmXZpw,5601
181
181
  howler/services/lucene_service.py,sha256=PRDM6cEnIA9qb1elpsiD2EVIXRo0rg6q0ki1QTu9UQA,12752
182
182
  howler/services/notebook_service.py,sha256=_MWllCnuVxt7lCcvWghXnaS926FbvBRE3DrBt--zO7U,3968
183
183
  howler/services/overview_service.py,sha256=-Jk6m1jRA0RQ9QuBxaguQbI0baH1Rz6N6H6R8E4jzJ8,1704
184
184
  howler/services/template_service.py,sha256=ts2vF6uimyuQbsocPSu-7wrI0-Av3ZjDAYWb53ERW-A,2051
185
- howler/services/user_service.py,sha256=AqqnfNJxvDKCYymfExtN7VWKlkBpSDbWnNsoGgpPVHw,12671
185
+ howler/services/user_service.py,sha256=ic_vnw5bXycSQmO1ohUifqvwDAJ6AKcNqBLP8hFPY54,13758
186
186
  howler/telemetry.py,sha256=bXjXhZXwiwcGDJAD2ZNN_Zh6aZd19cQqtmn3G47Ckac,2584
187
187
  howler/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
188
188
  howler/utils/annotations.py,sha256=GLuDbjbXp8esDji3qhQY_uQyOWVqfIdpF_zs2t0IaMI,878
@@ -196,7 +196,7 @@ howler/utils/path.py,sha256=DfOU4i4zSs4wchHoE8iE7aWVLkTxiC_JRGepF2hBYBk,690
196
196
  howler/utils/socket_utils.py,sha256=nz1SklC9xBHUSfHyTJjpq3mbozX1GDf01WzdGxfaUII,2212
197
197
  howler/utils/str_utils.py,sha256=HE8Hqh2HlOLaj16w0H9zKOyDJLp-f1LQ50y_WeGZaEk,8389
198
198
  howler/utils/uid.py,sha256=p9dsqyvZ-lpiAuzZWCPCeEM99kdk0Ly9czf04HNdSuw,1341
199
- howler_api-3.3.0.dev767.dist-info/METADATA,sha256=mSXzbRE4gGRPvdwASv3kiT5Yk0ZhCe-CFUChcuiOOLg,2879
200
- howler_api-3.3.0.dev767.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
201
- howler_api-3.3.0.dev767.dist-info/entry_points.txt,sha256=Lu9SBGvwe0wczJHmc-RudC24lmQk7tv3ZBXon9RIihg,259
202
- howler_api-3.3.0.dev767.dist-info/RECORD,,
199
+ howler_api-3.3.0.dev768.dist-info/METADATA,sha256=0ShN3xopxsQbeWQ2LoELT33OvpjicY67jiTl-jpDGJ0,2879
200
+ howler_api-3.3.0.dev768.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
201
+ howler_api-3.3.0.dev768.dist-info/entry_points.txt,sha256=Lu9SBGvwe0wczJHmc-RudC24lmQk7tv3ZBXon9RIihg,259
202
+ howler_api-3.3.0.dev768.dist-info/RECORD,,