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.
- {fred_core-3.2.0 → fred_core-3.4.0}/PKG-INFO +1 -1
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/config_loader.py +41 -1
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/structures.py +20 -1
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/__init__.py +2 -0
- fred_core-3.4.0/fred_core/documents/tag_models.py +26 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/base_history_store.py +22 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/postgres_history_store.py +15 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/oidc.py +69 -13
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/structure.py +9 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/__init__.py +6 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/models.py +18 -2
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/service.py +11 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_config_loader.py +28 -0
- fred_core-3.4.0/fred_core/tests/common/test_structures.py +47 -0
- fred_core-3.4.0/fred_core/tests/security/test_oidc_strict.py +101 -0
- fred_core-3.4.0/fred_core/tests/security/test_security_profile.py +98 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/PKG-INFO +1 -1
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/SOURCES.txt +4 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/pyproject.toml +1 -1
- {fred_core-3.2.0 → fred_core-3.4.0}/README.md +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/cli/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/cli/auth.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/cli/ui.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/config_files.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/env.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/fastapi_handlers.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/lru_cache.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/team_id.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/common/utils.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/document_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/document_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/document_structures.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/documents/postgres_document_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/gcs_filesystem.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/local_filesystem.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/minio_filesystem.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/filesystem/structures.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/history_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/history/history_schema.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/base_kpi_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/base_kpi_writer.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/http_middleware.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_factory.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_phase_metric.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_process.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_reader_structures.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_writer.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/kpi_writer_structures.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/log_kpi_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/noop_kpi_writer.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/opensearch_kpi_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/kpi/prometheus_kpi_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/base_log_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/log_setup.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/log_structures.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/memory_log_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/null_log_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/logs/opensearch_log_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/model/factory.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/model/http_clients.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/model/models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/models/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/models/base.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/portable/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/portable/observability.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/py.typed +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/backend.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/scheduler_structures.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/scheduler/temporal_client_provider.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/authorization.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/authorization_decorator.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/backend_to_backend_auth.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/keycloak/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/outbound.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rbac.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/noop_engine.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/openfga_engine.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/openfga_schema.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/rebac_engine.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/rebac_factory.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/schema.fga +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/rebac/schema.fga.json +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/security/whitelist_access_control/access_control.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/session_schema.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/base_session_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/postgres_session_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/session/stores/session_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/alembic_env.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/async_session.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/base_sql.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/sql/mixin.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/base_content_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/local_content_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/minio_content_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/opensearch_mapping_validator.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/store/vector_search.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/bus.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/orm_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/sse.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tasks/workflow_control.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/teams/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/teams/metadata_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/teams/team_metatada_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/cli/test_auth.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/cli/test_ui.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_env.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_fastapi_handlers.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_log_setup.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/common/test_lru_cache.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/filesystem/test_gcs_filesystem.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/filesystem/test_local_filesystem.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/integration/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/integration/test_rebac.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/kpi/test_http_middleware.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/kpi/test_noop_kpi_writer.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/logs/test_memory_log_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/model/test_embedding_factory.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/model/test_http_clients.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/portable/test_observability.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/scheduler/test_backend.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_authorization.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_authorization_decorator.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_rebac_config_defaults.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/security/test_whitelist_access_control.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/store/test_local_content_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/test_bus.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/test_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/tasks/test_reconcile.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/test_log_kpi_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/tests/test_prometheus_kpi_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/store/__init__.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/store/base_user_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/store/postgres_user_store.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core/users/user_models.py +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/dependency_links.txt +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/requires.txt +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/fred_core.egg-info/top_level.txt +0 -0
- {fred_core-3.2.0 → fred_core-3.4.0}/setup.cfg +0 -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
|
-
|
|
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
|
|
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
|
|
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
|
|
340
|
+
if iss and KEYCLOAK_URL and str(iss) != KEYCLOAK_URL:
|
|
288
341
|
logger.warning(
|
|
289
|
-
"[SECURITY] JWT issuer mismatch (soft): iss=%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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|