fred-core 3.2.0__tar.gz → 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. {fred_core-3.2.0 → fred_core-3.4.0}/PKG-INFO +1 -1
  2. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/config_loader.py +41 -1
  3. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/structures.py +20 -1
  4. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/__init__.py +2 -0
  5. fred_core-3.4.0/fred_core/documents/tag_models.py +26 -0
  6. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/base_history_store.py +22 -0
  7. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/postgres_history_store.py +15 -0
  8. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/oidc.py +69 -13
  9. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/structure.py +9 -0
  10. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/__init__.py +6 -0
  11. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/models.py +18 -2
  12. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/service.py +11 -0
  13. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_config_loader.py +28 -0
  14. fred_core-3.4.0/fred_core/tests/common/test_structures.py +47 -0
  15. fred_core-3.4.0/fred_core/tests/security/test_oidc_strict.py +101 -0
  16. fred_core-3.4.0/fred_core/tests/security/test_security_profile.py +98 -0
  17. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/PKG-INFO +1 -1
  18. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/SOURCES.txt +4 -0
  19. {fred_core-3.2.0 → fred_core-3.4.0}/pyproject.toml +1 -1
  20. {fred_core-3.2.0 → fred_core-3.4.0}/README.md +0 -0
  21. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/__init__.py +0 -0
  22. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/cli/__init__.py +0 -0
  23. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/cli/auth.py +0 -0
  24. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/cli/ui.py +0 -0
  25. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/__init__.py +0 -0
  26. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/config_files.py +0 -0
  27. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/env.py +0 -0
  28. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/fastapi_handlers.py +0 -0
  29. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/lru_cache.py +0 -0
  30. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/team_id.py +0 -0
  31. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/utils.py +0 -0
  32. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/document_models.py +0 -0
  33. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/document_store.py +0 -0
  34. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/document_structures.py +0 -0
  35. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/postgres_document_store.py +0 -0
  36. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/gcs_filesystem.py +0 -0
  37. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/local_filesystem.py +0 -0
  38. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/minio_filesystem.py +0 -0
  39. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/structures.py +0 -0
  40. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/__init__.py +0 -0
  41. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/history_models.py +0 -0
  42. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/history_schema.py +0 -0
  43. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/__init__.py +0 -0
  44. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/base_kpi_store.py +0 -0
  45. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/base_kpi_writer.py +0 -0
  46. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/http_middleware.py +0 -0
  47. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_factory.py +0 -0
  48. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_phase_metric.py +0 -0
  49. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_process.py +0 -0
  50. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_reader_structures.py +0 -0
  51. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_writer.py +0 -0
  52. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_writer_structures.py +0 -0
  53. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/log_kpi_store.py +0 -0
  54. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/noop_kpi_writer.py +0 -0
  55. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/opensearch_kpi_store.py +0 -0
  56. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/prometheus_kpi_store.py +0 -0
  57. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/__init__.py +0 -0
  58. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/base_log_store.py +0 -0
  59. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/log_setup.py +0 -0
  60. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/log_structures.py +0 -0
  61. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/memory_log_store.py +0 -0
  62. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/null_log_store.py +0 -0
  63. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/opensearch_log_store.py +0 -0
  64. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/model/factory.py +0 -0
  65. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/model/http_clients.py +0 -0
  66. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/model/models.py +0 -0
  67. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/models/__init__.py +0 -0
  68. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/models/base.py +0 -0
  69. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/portable/__init__.py +0 -0
  70. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/portable/observability.py +0 -0
  71. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/py.typed +0 -0
  72. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/__init__.py +0 -0
  73. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/backend.py +0 -0
  74. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/scheduler_structures.py +0 -0
  75. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/temporal_client_provider.py +0 -0
  76. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/__init__.py +0 -0
  77. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/authorization.py +0 -0
  78. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/authorization_decorator.py +0 -0
  79. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/backend_to_backend_auth.py +0 -0
  80. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/keycloak/__init__.py +0 -0
  81. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
  82. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/models.py +0 -0
  83. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/outbound.py +0 -0
  84. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rbac.py +0 -0
  85. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/noop_engine.py +0 -0
  86. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/openfga_engine.py +0 -0
  87. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/openfga_schema.py +0 -0
  88. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/rebac_engine.py +0 -0
  89. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/rebac_factory.py +0 -0
  90. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/schema.fga +0 -0
  91. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/schema.fga.json +0 -0
  92. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/whitelist_access_control/access_control.py +0 -0
  93. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/__init__.py +0 -0
  94. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/session_schema.py +0 -0
  95. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/__init__.py +0 -0
  96. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/base_session_store.py +0 -0
  97. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/postgres_session_store.py +0 -0
  98. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/session_models.py +0 -0
  99. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/__init__.py +0 -0
  100. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/alembic_env.py +0 -0
  101. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/async_session.py +0 -0
  102. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/base_sql.py +0 -0
  103. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/mixin.py +0 -0
  104. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/__init__.py +0 -0
  105. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/base_content_store.py +0 -0
  106. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/local_content_store.py +0 -0
  107. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/minio_content_store.py +0 -0
  108. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/opensearch_mapping_validator.py +0 -0
  109. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/vector_search.py +0 -0
  110. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/bus.py +0 -0
  111. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/orm_models.py +0 -0
  112. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/sse.py +0 -0
  113. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/store.py +0 -0
  114. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/workflow_control.py +0 -0
  115. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/teams/__init__.py +0 -0
  116. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/teams/metadata_store.py +0 -0
  117. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/teams/team_metatada_models.py +0 -0
  118. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/__init__.py +0 -0
  119. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/cli/test_auth.py +0 -0
  120. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/cli/test_ui.py +0 -0
  121. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_env.py +0 -0
  122. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_fastapi_handlers.py +0 -0
  123. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_log_setup.py +0 -0
  124. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_lru_cache.py +0 -0
  125. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/filesystem/test_gcs_filesystem.py +0 -0
  126. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/filesystem/test_local_filesystem.py +0 -0
  127. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/integration/__init__.py +0 -0
  128. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/integration/test_rebac.py +0 -0
  129. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/kpi/test_http_middleware.py +0 -0
  130. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/kpi/test_noop_kpi_writer.py +0 -0
  131. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/logs/test_memory_log_store.py +0 -0
  132. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/model/test_embedding_factory.py +0 -0
  133. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/model/test_http_clients.py +0 -0
  134. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
  135. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/portable/test_observability.py +0 -0
  136. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/scheduler/test_backend.py +0 -0
  137. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/__init__.py +0 -0
  138. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_authorization.py +0 -0
  139. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_authorization_decorator.py +0 -0
  140. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_rebac_config_defaults.py +0 -0
  141. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -0
  142. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_whitelist_access_control.py +0 -0
  143. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
  144. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/store/test_local_content_store.py +0 -0
  145. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/__init__.py +0 -0
  146. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/test_bus.py +0 -0
  147. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/test_models.py +0 -0
  148. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/test_reconcile.py +0 -0
  149. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/test_log_kpi_store.py +0 -0
  150. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/test_prometheus_kpi_store.py +0 -0
  151. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/__init__.py +0 -0
  152. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/store/__init__.py +0 -0
  153. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/store/base_user_store.py +0 -0
  154. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/store/postgres_user_store.py +0 -0
  155. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/user_models.py +0 -0
  156. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/dependency_links.txt +0 -0
  157. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/requires.txt +0 -0
  158. {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/top_level.txt +0 -0
  159. {fred_core-3.2.0 → fred_core-3.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-core
3
- Version: 3.2.0
3
+ Version: 3.4.0
4
4
  Summary: Core shared utilities for Fred backends (config, storage, security, and runtime helpers).
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.0
@@ -14,15 +14,49 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import sys
17
18
  from typing import Callable, TypeVar
18
19
 
19
20
  import yaml
21
+ from pydantic import ValidationError
20
22
 
21
23
  from .config_files import ConfigFiles
22
24
 
23
25
  TConfig = TypeVar("TConfig")
24
26
 
25
27
 
28
+ def _render_config_error_banner(config_file: str, error: Exception) -> None:
29
+ """Print a loud, unmissable configuration-error banner to stderr.
30
+
31
+ A misconfigured service must not start silently and fail later with an
32
+ opaque error inside a request handler. We surface the root cause in red at
33
+ startup. Colours are emitted only on a TTY so log files stay clean.
34
+ """
35
+ use_colour = sys.stderr.isatty()
36
+ red = "\033[1;31m" if use_colour else ""
37
+ reset = "\033[0m" if use_colour else ""
38
+ bar = "=" * 78
39
+
40
+ if isinstance(error, ValidationError):
41
+ details = "\n".join(
42
+ f" - {' -> '.join(str(p) for p in err['loc']) or '(root)'}: {err['msg']}"
43
+ for err in error.errors()
44
+ )
45
+ else:
46
+ details = f" - {error}"
47
+
48
+ print(
49
+ f"\n{red}{bar}\n"
50
+ f" CONFIGURATION ERROR — refusing to start\n"
51
+ f" file: {config_file}\n"
52
+ f"{bar}{reset}\n"
53
+ f"{details}\n"
54
+ f"{red}{bar}{reset}\n",
55
+ file=sys.stderr,
56
+ flush=True,
57
+ )
58
+
59
+
26
60
  def parse_yaml_mapping_file(config_file: str) -> dict:
27
61
  """Load a YAML file and ensure it is a non-empty mapping."""
28
62
  with open(config_file, encoding="utf-8") as file:
@@ -42,7 +76,13 @@ def load_configuration_with_config_files(
42
76
  """Load env + config path using ConfigFiles and parse via callback."""
43
77
  config_files.load_environment(dotenv_path)
44
78
  config_file = config_files.resolve_config_file_path()
45
- configuration = parser(config_file)
79
+ try:
80
+ configuration = parser(config_file)
81
+ except (ValidationError, ValueError) as exc:
82
+ # Render the root cause in red and stop, rather than letting an opaque
83
+ # traceback (or a deferred runtime 401) bury what is wrong.
84
+ _render_config_error_banner(config_file, exc)
85
+ raise SystemExit(1) from exc
46
86
  config_files.mark_config_loaded(config_file)
47
87
  return configuration
48
88
 
@@ -16,7 +16,7 @@ import os
16
16
  from enum import Enum
17
17
  from typing import Annotated, Any, Dict, Literal, Optional, Union
18
18
 
19
- from pydantic import BaseModel, Field
19
+ from pydantic import BaseModel, Field, model_validator
20
20
 
21
21
 
22
22
  class OwnerFilter(str, Enum):
@@ -81,6 +81,25 @@ class OpenSearchStoreConfig(BaseModel):
81
81
  secure: bool = Field(default=False, description="Use TLS (https)")
82
82
  verify_certs: bool = Field(default=False, description="Verify TLS certs")
83
83
 
84
+ @model_validator(mode="after")
85
+ def _require_password(self) -> "OpenSearchStoreConfig":
86
+ """Fail fast at config load if OpenSearch is configured without credentials.
87
+
88
+ Reaching this validator means a ``storage.opensearch`` block is present in
89
+ the active configuration, so the service genuinely depends on OpenSearch.
90
+ A missing password only surfaces later as an opaque HTTP 401 deep inside a
91
+ request handler — we convert it into an actionable startup failure instead.
92
+ """
93
+ if not self.password:
94
+ raise ValueError(
95
+ f"OpenSearch is configured (host={self.host!r}, username="
96
+ f"{self.username!r}) but no password was provided. "
97
+ "Set the OPENSEARCH_PASSWORD environment variable (in your .env) "
98
+ "to the OpenSearch password for this user, or remove the "
99
+ "storage.opensearch block if OpenSearch is not used."
100
+ )
101
+ return self
102
+
84
103
 
85
104
  class OpenSearchIndexConfig(BaseModel):
86
105
  type: Literal["opensearch"]
@@ -38,6 +38,7 @@ from fred_core.documents.document_structures import (
38
38
  Tagging,
39
39
  )
40
40
  from fred_core.documents.postgres_document_store import PostgresDocumentMetadataStore
41
+ from fred_core.documents.tag_models import TagRow
41
42
 
42
43
  __all__ = [
43
44
  # Pydantic models / enums
@@ -64,4 +65,5 @@ __all__ = [
64
65
  "DocumentMetadataDeserializationError",
65
66
  "DocumentMetadataRow",
66
67
  "PostgresDocumentMetadataStore",
68
+ "TagRow",
67
69
  ]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import String
6
+ from sqlalchemy.orm import Mapped, mapped_column
7
+
8
+ from fred_core.models.base import Base, JsonColumn, TimestampColumn
9
+
10
+
11
+ class TagRow(Base):
12
+ """ORM model for the ``tag`` table — shared between knowledge-flow and the importer."""
13
+
14
+ __tablename__ = "tag"
15
+
16
+ tag_id: Mapped[str] = mapped_column(String, primary_key=True)
17
+ created_at: Mapped[datetime | None] = mapped_column(TimestampColumn, nullable=True)
18
+ updated_at: Mapped[datetime | None] = mapped_column(
19
+ TimestampColumn, index=True, nullable=True
20
+ )
21
+ owner_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
22
+ name: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
23
+ path: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
24
+ description: Mapped[str | None] = mapped_column(String, nullable=True)
25
+ type: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
26
+ doc: Mapped[dict | None] = mapped_column(JsonColumn, nullable=True)
@@ -148,6 +148,22 @@ class BaseHistoryStore(ABC):
148
148
  """
149
149
  raise NotImplementedError
150
150
 
151
+ async def session_exists(
152
+ self,
153
+ session_id: str,
154
+ ) -> bool:
155
+ """
156
+ Return True iff any history row exists for ``session_id`` (any owner).
157
+
158
+ Why this exists:
159
+ - paired with :meth:`session_belongs_to_user`, this lets a caller enforce a
160
+ private-per-owner policy: a session that EXISTS but does not belong to the
161
+ authenticated user must be refused (RUNTIME-07 rev. 2, finding F-C). It
162
+ distinguishes a brand-new session (allow — the caller becomes owner) from
163
+ another user's session (deny).
164
+ """
165
+ raise NotImplementedError
166
+
151
167
 
152
168
  class NoOpHistoryStore(BaseHistoryStore):
153
169
  """
@@ -199,3 +215,9 @@ class NoOpHistoryStore(BaseHistoryStore):
199
215
  user_id: str,
200
216
  ) -> bool:
201
217
  return False
218
+
219
+ async def session_exists(
220
+ self,
221
+ session_id: str,
222
+ ) -> bool:
223
+ return False
@@ -368,6 +368,21 @@ class PostgresHistoryStore(BaseHistoryStore):
368
368
  )
369
369
  return (result.scalar() or 0) > 0
370
370
 
371
+ async def session_exists(
372
+ self,
373
+ session_id: str,
374
+ session: AsyncSession | None = None,
375
+ ) -> bool:
376
+ """Return True iff any history row exists for ``session_id`` (any owner)."""
377
+ await self._ensure_tables()
378
+ async with use_session(self._sessions, session) as s:
379
+ result = await s.execute(
380
+ select(func.count()).where(
381
+ SessionHistoryRow.session_id == session_id,
382
+ )
383
+ )
384
+ return (result.scalar() or 0) > 0
385
+
371
386
  # ------------------------------------------------------------------
372
387
  # Rank helper
373
388
  # ------------------------------------------------------------------
@@ -28,7 +28,11 @@ from fastapi.security import OAuth2PasswordBearer
28
28
  from jwt import PyJWKClient
29
29
 
30
30
  from fred_core.common import ThreadSafeLRUCache, get_config, read_env_bool
31
- from fred_core.security.structure import KeycloakUser, UserSecurity
31
+ from fred_core.security.structure import (
32
+ KeycloakUser,
33
+ SecurityConfiguration,
34
+ UserSecurity,
35
+ )
32
36
  from fred_core.security.whitelist_access_control.access_control import (
33
37
  is_user_whitelisted,
34
38
  is_whitelist_active,
@@ -138,6 +142,54 @@ def initialize_user_security(config: UserSecurity):
138
142
  )
139
143
 
140
144
 
145
+ def apply_security_profile(config: SecurityConfiguration) -> None:
146
+ """
147
+ Enforce a hardened security profile at startup (RUNTIME-07 rev. 2, F5/F6).
148
+
149
+ When ``security.profile == 'c3'``:
150
+ - force strict JWT issuer + audience validation (closes F5 soft defaults),
151
+ - require user + m2m auth enabled — no no-security / mock-admin (F6),
152
+ - require ReBAC (OpenFGA) enabled, so the pod authorizes every request and
153
+ fails closed (no permissive Noop engine).
154
+
155
+ The control-plane issues NO signed grant; authorization is decided at the pod
156
+ by a Keycloak JWT + OpenFGA check. Raises ValueError on any violation so the
157
+ service FAILS CLOSED — it refuses to start in an insecure configuration rather
158
+ than silently degrading. No-op for any non-c3 profile (dev behavior unchanged).
159
+ """
160
+ global STRICT_ISSUER, STRICT_AUDIENCE
161
+
162
+ if config.profile != "c3":
163
+ return
164
+
165
+ STRICT_ISSUER = True
166
+ STRICT_AUDIENCE = True
167
+
168
+ violations: list[str] = []
169
+ if not config.user.enabled:
170
+ violations.append(
171
+ "security.user.enabled must be true (no-security/mock-admin is forbidden)"
172
+ )
173
+ if not config.m2m.enabled:
174
+ violations.append("security.m2m.enabled must be true")
175
+ rebac = config.rebac
176
+ if rebac is None or not rebac.enabled:
177
+ violations.append(
178
+ "security.rebac.enabled must be true (pod-side OpenFGA authorization "
179
+ "is mandatory and must fail closed)"
180
+ )
181
+
182
+ if violations:
183
+ raise ValueError(
184
+ "C3 security profile violations (refusing to start): "
185
+ + "; ".join(violations)
186
+ )
187
+
188
+ logger.info(
189
+ "[SECURITY] C3 profile active: strict issuer+audience, OpenFGA ReBAC enforced"
190
+ )
191
+
192
+
141
193
  def split_realm_url(realm_url: str) -> tuple[str, str]:
142
194
  """
143
195
  Split a Keycloak realm URL like:
@@ -281,18 +333,16 @@ def decode_jwt(token: str) -> KeycloakUser:
281
333
  _iso(payload_peek.get("nbf")),
282
334
  )
283
335
 
284
- # Soft checks (warn-only unless STRICT_* enabled)
336
+ # Soft observability logs only. Strict enforcement (C3) happens in jwt.decode
337
+ # below, on the SIGNATURE-VERIFIED payload, via exact issuer/audience.
285
338
  iss = payload_peek.get("iss")
286
339
  aud = payload_peek.get("aud")
287
- if iss and KEYCLOAK_URL and not str(iss).startswith(KEYCLOAK_URL):
340
+ if iss and KEYCLOAK_URL and str(iss) != KEYCLOAK_URL:
288
341
  logger.warning(
289
- "[SECURITY] JWT issuer mismatch (soft): iss=%s expected_prefix=%s",
342
+ "[SECURITY] JWT issuer mismatch (soft): iss=%s expected=%s",
290
343
  iss,
291
344
  KEYCLOAK_URL,
292
345
  )
293
- if STRICT_ISSUER:
294
- raise HTTPException(status_code=401, detail="Invalid token issuer")
295
-
296
346
  if KEYCLOAK_CLIENT_ID:
297
347
  aud_list = aud if isinstance(aud, list) else [aud] if aud else []
298
348
  if KEYCLOAK_CLIENT_ID not in aud_list:
@@ -301,8 +351,6 @@ def decode_jwt(token: str) -> KeycloakUser:
301
351
  aud_list,
302
352
  KEYCLOAK_CLIENT_ID,
303
353
  )
304
- if STRICT_AUDIENCE:
305
- raise HTTPException(status_code=401, detail="Invalid token audience")
306
354
 
307
355
  # JWKS fetch + decode
308
356
  try:
@@ -320,15 +368,23 @@ def decode_jwt(token: str) -> KeycloakUser:
320
368
  headers={"WWW-Authenticate": "Bearer error='invalid_token'"},
321
369
  )
322
370
 
371
+ # Under the C3 profile, STRICT_AUDIENCE/STRICT_ISSUER are set: PyJWT then
372
+ # enforces exact audience (== client_id) and exact issuer (== realm URL) on the
373
+ # verified payload, and rejects a confused `alg` (algorithms pinned to RS256).
374
+ # In dev (soft) we keep verification of signature + expiry only.
375
+ verify_aud = bool(STRICT_AUDIENCE and KEYCLOAK_CLIENT_ID)
376
+ expected_audience: str | None = KEYCLOAK_CLIENT_ID if verify_aud else None
377
+ expected_issuer: str | None = (
378
+ KEYCLOAK_URL if (STRICT_ISSUER and KEYCLOAK_URL) else None
379
+ )
323
380
  try:
324
381
  payload = jwt.decode(
325
382
  token,
326
383
  signing_key,
327
384
  algorithms=["RS256"],
328
- options={
329
- "verify_exp": True,
330
- "verify_aud": False,
331
- }, # we do soft aud check above
385
+ audience=expected_audience,
386
+ issuer=expected_issuer,
387
+ options={"verify_exp": True, "verify_aud": verify_aud},
332
388
  leeway=CLOCK_SKEW_SECONDS,
333
389
  )
334
390
  logger.debug("[SECURITY] JWT token successfully decoded")
@@ -101,3 +101,12 @@ class SecurityConfiguration(BaseModel):
101
101
  user: UserSecurity
102
102
  authorized_origins: List[AnyHttpUrl] = []
103
103
  rebac: RebacConfiguration | None = None
104
+ profile: Literal["c3"] | None = Field(
105
+ default=None,
106
+ description=(
107
+ "Hardened security profile (RUNTIME-07). 'c3' forces strict JWT "
108
+ "issuer/audience validation, forbids no-security/mock-admin, and "
109
+ "requires OpenFGA ReBAC enabled (pod-side authorization, fail-closed) "
110
+ "— failing startup otherwise. The control-plane issues no signed grant."
111
+ ),
112
+ )
@@ -19,10 +19,13 @@ from fred_core.tasks.models import (
19
19
  IngestionDetail,
20
20
  IngestionProcessingProfile,
21
21
  IngestionTaskEvent,
22
+ MigrationDetail,
23
+ MigrationTaskEvent,
22
24
  StartEvaluationParams,
23
25
  StartEvaluationRequest,
24
26
  StartIngestionParams,
25
27
  StartIngestionRequest,
28
+ StartMigrationRequest,
26
29
  StartTaskRequest,
27
30
  StartTaskResponse,
28
31
  TaskEvent,
@@ -51,6 +54,8 @@ __all__ = [
51
54
  "IngestionProcessingProfile",
52
55
  "TaskEvent",
53
56
  "IngestionTaskEvent",
57
+ "MigrationDetail",
58
+ "MigrationTaskEvent",
54
59
  "EvaluationTaskEvent",
55
60
  "TaskLogEvent",
56
61
  "IngestionDetail",
@@ -59,6 +64,7 @@ __all__ = [
59
64
  "StartTaskRequest",
60
65
  "StartTaskResponse",
61
66
  "StartIngestionRequest",
67
+ "StartMigrationRequest",
62
68
  "StartIngestionParams",
63
69
  "StartEvaluationRequest",
64
70
  "StartEvaluationParams",
@@ -113,8 +113,20 @@ class TaskLogEvent(_TaskEventBase):
113
113
  detail: TaskLogDetail
114
114
 
115
115
 
116
+ class MigrationDetail(BaseModel):
117
+ step_id: str
118
+ processed: int
119
+ total: int
120
+ failed: int
121
+
122
+
123
+ class MigrationTaskEvent(_TaskEventBase):
124
+ kind: Literal["migration"] = "migration"
125
+ detail: MigrationDetail | None = None
126
+
127
+
116
128
  TaskEvent = Annotated[
117
- Union[IngestionTaskEvent, EvaluationTaskEvent, TaskLogEvent],
129
+ Union[IngestionTaskEvent, EvaluationTaskEvent, TaskLogEvent, MigrationTaskEvent],
118
130
  Field(discriminator="kind"),
119
131
  ]
120
132
 
@@ -141,8 +153,12 @@ class StartEvaluationRequest(BaseModel):
141
153
  params: StartEvaluationParams
142
154
 
143
155
 
156
+ class StartMigrationRequest(BaseModel):
157
+ kind: Literal["migration"] = "migration"
158
+
159
+
144
160
  StartTaskRequest = Annotated[
145
- Union[StartIngestionRequest, StartEvaluationRequest],
161
+ Union[StartIngestionRequest, StartEvaluationRequest, StartMigrationRequest],
146
162
  Field(discriminator="kind"),
147
163
  ]
148
164
 
@@ -24,6 +24,7 @@ from fred_core.scheduler import SchedulerBackend, TemporalClientProvider
24
24
  from fred_core.tasks.bus import IEventBus, MemoryEventBus, PostgresEventBus
25
25
  from fred_core.tasks.models import (
26
26
  IngestionTaskEvent,
27
+ MigrationTaskEvent,
27
28
  StartTaskRequest,
28
29
  StartTaskResponse,
29
30
  TaskEvent,
@@ -193,6 +194,16 @@ class TaskService:
193
194
  owner=run.created_by,
194
195
  detail=TaskLogDetail(level=level, message=message),
195
196
  )
197
+ if run.kind == "migration":
198
+ return MigrationTaskEvent(
199
+ task_id=run.task_id,
200
+ state=state,
201
+ seq=0, # reassigned by record()
202
+ timestamp=_utcnow(),
203
+ error=message,
204
+ target=target,
205
+ owner=run.created_by,
206
+ )
196
207
  # ingestion (and any future progress-counter kind) — detail is optional
197
208
  return IngestionTaskEvent(
198
209
  task_id=run.task_id,
@@ -81,3 +81,31 @@ def test_load_configuration_with_config_files_tracks_loaded_paths(
81
81
  assert configuration == {"app": {"name": "fred"}}
82
82
  assert config_files.get_loaded_env_file_path() == str(env_file)
83
83
  assert config_files.get_loaded_config_file_path() == str(config_file)
84
+
85
+
86
+ def test_load_configuration_renders_banner_and_exits_on_error(
87
+ tmp_path, monkeypatch, capsys
88
+ ) -> None:
89
+ config_file = tmp_path / "configuration.yaml"
90
+ config_file.write_text("app:\n name: fred\n", encoding="utf-8")
91
+
92
+ monkeypatch.delenv("ENV_FILE", raising=False)
93
+ monkeypatch.delenv("CONFIG_FILE", raising=False)
94
+
95
+ config_files = ConfigFiles(
96
+ logger=logging.getLogger("fred_core.tests.config_loader"),
97
+ default_config_file=str(config_file),
98
+ )
99
+
100
+ def failing_parser(_path: str) -> dict:
101
+ raise ValueError("Set the OPENSEARCH_PASSWORD environment variable")
102
+
103
+ with pytest.raises(SystemExit) as exc_info:
104
+ load_configuration_with_config_files(config_files, failing_parser)
105
+
106
+ assert exc_info.value.code == 1
107
+ err = capsys.readouterr().err
108
+ assert "CONFIGURATION ERROR" in err
109
+ assert "OPENSEARCH_PASSWORD" in err
110
+ # An aborted load must not record the config as successfully loaded.
111
+ assert config_files.get_loaded_config_file_path() is None
@@ -0,0 +1,47 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import pytest
18
+ from pydantic import ValidationError
19
+
20
+ from fred_core.common import OpenSearchStoreConfig
21
+
22
+
23
+ def test_opensearch_config_requires_password(monkeypatch) -> None:
24
+ monkeypatch.delenv("OPENSEARCH_PASSWORD", raising=False)
25
+
26
+ with pytest.raises(ValidationError, match="OPENSEARCH_PASSWORD"):
27
+ OpenSearchStoreConfig(host="https://localhost:9200", username="admin")
28
+
29
+
30
+ def test_opensearch_config_password_from_env(monkeypatch) -> None:
31
+ monkeypatch.setenv("OPENSEARCH_PASSWORD", "secret") # pragma: allowlist secret
32
+
33
+ cfg = OpenSearchStoreConfig(host="https://localhost:9200", username="admin")
34
+
35
+ assert cfg.password == "secret" # nosec B105 # pragma: allowlist secret
36
+
37
+
38
+ def test_opensearch_config_explicit_password_wins(monkeypatch) -> None:
39
+ monkeypatch.delenv("OPENSEARCH_PASSWORD", raising=False)
40
+
41
+ cfg = OpenSearchStoreConfig(
42
+ host="https://localhost:9200",
43
+ username="admin",
44
+ password="inline", # nosec B106 # pragma: allowlist secret
45
+ )
46
+
47
+ assert cfg.password == "inline" # nosec B105 # pragma: allowlist secret
@@ -0,0 +1,101 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License").
4
+
5
+ """Strict JWT validation under the C3 profile (RUNTIME-07 rev. 2, finding F-E).
6
+
7
+ When STRICT_ISSUER / STRICT_AUDIENCE are set, decode_jwt must enforce EXACT issuer
8
+ and audience on the signature-verified payload (PyJWT verify_aud=True), not a
9
+ prefix/peek check. These tests sign real RS256 tokens and mock only the JWKS key
10
+ resolution.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from types import SimpleNamespace
17
+
18
+ import jwt as pyjwt
19
+ import pytest
20
+ from cryptography.hazmat.primitives import serialization
21
+ from cryptography.hazmat.primitives.asymmetric import rsa
22
+ from fastapi import HTTPException
23
+
24
+ from fred_core.security import oidc
25
+
26
+ _REALM = "http://localhost:8080/realms/app"
27
+ _CLIENT = "app"
28
+
29
+
30
+ @pytest.fixture
31
+ def _rsa_keypair():
32
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
33
+ priv_pem = key.private_bytes(
34
+ serialization.Encoding.PEM,
35
+ serialization.PrivateFormat.PKCS8,
36
+ serialization.NoEncryption(),
37
+ )
38
+ return priv_pem, key.public_key()
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def _strict_keycloak(monkeypatch, _rsa_keypair):
43
+ _, public_key = _rsa_keypair
44
+ monkeypatch.setattr(oidc, "KEYCLOAK_ENABLED", True)
45
+ monkeypatch.setattr(oidc, "KEYCLOAK_URL", _REALM)
46
+ monkeypatch.setattr(oidc, "KEYCLOAK_CLIENT_ID", _CLIENT)
47
+ monkeypatch.setattr(oidc, "STRICT_ISSUER", True)
48
+ monkeypatch.setattr(oidc, "STRICT_AUDIENCE", True)
49
+ monkeypatch.setattr(
50
+ oidc,
51
+ "_get_jwks_client",
52
+ lambda: SimpleNamespace(
53
+ get_signing_key_from_jwt=lambda token: SimpleNamespace(key=public_key)
54
+ ),
55
+ )
56
+
57
+
58
+ def _token(priv_pem: bytes, *, iss: str, aud: str, sub: str = "u-1") -> str:
59
+ return pyjwt.encode(
60
+ {
61
+ "iss": iss,
62
+ "aud": aud,
63
+ "sub": sub,
64
+ "preferred_username": "alice",
65
+ "exp": int(time.time()) + 3600,
66
+ },
67
+ priv_pem,
68
+ algorithm="RS256",
69
+ )
70
+
71
+
72
+ def test_strict_accepts_exact_issuer_and_audience(_rsa_keypair):
73
+ priv_pem, _ = _rsa_keypair
74
+ user = oidc.decode_jwt(_token(priv_pem, iss=_REALM, aud=_CLIENT))
75
+ assert user.uid == "u-1"
76
+
77
+
78
+ def test_strict_rejects_wrong_audience(_rsa_keypair):
79
+ priv_pem, _ = _rsa_keypair
80
+ with pytest.raises(HTTPException) as exc:
81
+ oidc.decode_jwt(_token(priv_pem, iss=_REALM, aud="some-other-client"))
82
+ assert exc.value.status_code == 401
83
+
84
+
85
+ def test_strict_rejects_wrong_issuer(_rsa_keypair):
86
+ priv_pem, _ = _rsa_keypair
87
+ with pytest.raises(HTTPException) as exc:
88
+ oidc.decode_jwt(
89
+ _token(priv_pem, iss="http://evil/realms/app", aud=_CLIENT, sub="u-2")
90
+ )
91
+ assert exc.value.status_code == 401
92
+
93
+
94
+ def test_strict_rejects_issuer_prefix_attack(_rsa_keypair):
95
+ """A prefix-matching issuer must be rejected under exact-match strict mode."""
96
+ priv_pem, _ = _rsa_keypair
97
+ with pytest.raises(HTTPException) as exc:
98
+ oidc.decode_jwt(
99
+ _token(priv_pem, iss=_REALM + ".evil.com", aud=_CLIENT, sub="u-3")
100
+ )
101
+ assert exc.value.status_code == 401