diff --git a/examples/deployments/basic-ollama/config.yaml b/examples/deployments/basic-ollama/config.yaml index e7dfaa691..1c83b9653 100644 --- a/examples/deployments/basic-ollama/config.yaml +++ b/examples/deployments/basic-ollama/config.yaml @@ -31,7 +31,7 @@ services: port: 7889 external_port: 7889 auth: - enabled: false # set to true and provide DM_API_TOKEN in .env for production + enabled: false # set to true for protected direct access; DM_API_TOKEN optionally overrides the default internal service token data_manager: sources: diff --git a/examples/deployments/basic-openai/config.yaml b/examples/deployments/basic-openai/config.yaml index 8ab55024a..c76542d39 100644 --- a/examples/deployments/basic-openai/config.yaml +++ b/examples/deployments/basic-openai/config.yaml @@ -35,7 +35,7 @@ services: port: 7889 external_port: 7889 auth: - enabled: false # set to true and provide DM_API_TOKEN in .env for production + enabled: false # set to true for protected direct access; DM_API_TOKEN optionally overrides the default internal service token data_manager: sources: diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index 6c3e877dc..8de0efd2e 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -48,6 +48,7 @@ from src.utils.logging import get_logger from src.utils.config_access import get_full_config, get_services_config, get_global_config, get_dynamic_config from src.utils.config_service import ConfigService +from src.utils.internal_auth import build_data_manager_auth_headers, resolve_data_manager_service_token from src.utils.sql import ( SQL_INSERT_CONVO, SQL_INSERT_FEEDBACK, SQL_INSERT_TIMING, SQL_QUERY_CONVO, SQL_CREATE_CONVERSATION, SQL_UPDATE_CONVERSATION_TIMESTAMP, @@ -2153,9 +2154,13 @@ def __init__(self, app, **configs): dm_host = dm_config.get("hostname") or dm_config.get("host", "localhost") dm_port = dm_config.get("port", 5001) self.data_manager_url = f"http://{dm_host}:{dm_port}" - # API token for service-to-service auth with data-manager - dm_token = read_secret("DM_API_TOKEN") or None - self._dm_headers = {"Authorization": f"Bearer {dm_token}"} if dm_token else {} + # Internal auth for service-to-service calls into data-manager + self._dm_headers = build_data_manager_auth_headers() + _, dm_auth_source = resolve_data_manager_service_token() + if self._dm_headers: + logger.info("Data manager proxy auth initialized via %s", dm_auth_source) + else: + logger.warning("Data manager proxy auth unavailable; uploads may fail if data-manager auth is enabled") logger.info(f"Data manager service URL: {self.data_manager_url}") # Initialize authentication methods diff --git a/src/interfaces/uploader_app/app.py b/src/interfaces/uploader_app/app.py index f7fd20cc8..3a85465f0 100644 --- a/src/interfaces/uploader_app/app.py +++ b/src/interfaces/uploader_app/app.py @@ -20,6 +20,7 @@ from src.data_manager.vectorstore.loader_utils import load_text_from_path from src.interfaces.chat_app.document_utils import check_credentials from src.utils.env import read_secret +from src.utils.internal_auth import resolve_data_manager_service_token from src.utils.logging import get_logger from src.data_manager.collectors.utils.catalog_postgres import _METADATA_COLUMN_MAP from src.utils.config_access import get_full_config @@ -58,7 +59,8 @@ def __init__( self.auth_config = (self.services_config or {}).get("data_manager", {}).get("auth", {}) or {} self.auth_enabled = bool(self.auth_config.get("enabled", False)) - self.api_token = read_secret("DM_API_TOKEN") or None + self.api_token, self.api_token_source = resolve_data_manager_service_token() + self.api_token = self.api_token or None self.admin_users = { user.strip().lower() for user in (self.auth_config.get("admins") or []) @@ -69,6 +71,10 @@ def __init__( self.salt = read_secret("UPLOADER_SALT") self.accounts_path = self.global_config.get("ACCOUNTS_PATH") if self.auth_enabled: + if self.api_token: + logger.info("Data-manager API auth initialized via %s", self.api_token_source) + else: + logger.warning("Data-manager auth is enabled but no internal API token could be resolved") if not self.accounts_path: logger.warning("ACCOUNTS_PATH not configured; only default auth account avilable. Set is as DM_ADMIN_PASSWD in your secrets file.") self.auth_enabled = True diff --git a/src/utils/internal_auth.py b/src/utils/internal_auth.py new file mode 100644 index 000000000..a0cb2ec7c --- /dev/null +++ b/src/utils/internal_auth.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import hashlib +from typing import Dict, Tuple + +from src.utils.env import read_secret + + +def resolve_data_manager_service_token() -> Tuple[str, str]: + """Return the internal token used by chat-app -> data-manager requests. + + Prefer an explicit DM_API_TOKEN when provided. If it is absent, derive a + deterministic internal-only token from PG_PASSWORD so paired services can + still authenticate without extra configuration. + """ + explicit_token = read_secret("DM_API_TOKEN") + if explicit_token: + return explicit_token, "DM_API_TOKEN" + + pg_password = read_secret("PG_PASSWORD") + if not pg_password: + return "", "missing" + + digest = hashlib.sha256() + digest.update(b"archi:data-manager:") + digest.update(pg_password.encode("utf-8")) + return digest.hexdigest(), "derived-from-PG_PASSWORD" + + +def build_data_manager_auth_headers() -> Dict[str, str]: + """Build auth headers for requests to the data-manager service.""" + token, _ = resolve_data_manager_service_token() + return {"Authorization": f"Bearer {token}"} if token else {} diff --git a/tests/unit/test_internal_auth.py b/tests/unit/test_internal_auth.py new file mode 100644 index 000000000..e86c925bf --- /dev/null +++ b/tests/unit/test_internal_auth.py @@ -0,0 +1,61 @@ +import sys +from unittest.mock import MagicMock, patch + +from flask import Flask + + +def _secret_reader(values): + def _read_secret(name, default=""): + return values.get(name, default) + + return _read_secret + + +def test_resolve_data_manager_service_token_prefers_explicit_token(): + from src.utils.internal_auth import resolve_data_manager_service_token + + with patch("src.utils.internal_auth.read_secret", side_effect=_secret_reader({ + "DM_API_TOKEN": "dm-explicit-token", + "PG_PASSWORD": "postgres-password", + })): + token, source = resolve_data_manager_service_token() + + assert token == "dm-explicit-token" + assert source == "DM_API_TOKEN" + + +def test_resolve_data_manager_service_token_falls_back_to_pg_password(): + from src.utils.internal_auth import resolve_data_manager_service_token + + with patch("src.utils.internal_auth.read_secret", side_effect=_secret_reader({ + "PG_PASSWORD": "postgres-password", + })): + token, source = resolve_data_manager_service_token() + + assert token == "33d35de0f59af35976eb8d71af05e4da047068346ee196f078f0e7113ecd2328" + assert source == "derived-from-PG_PASSWORD" + + +def test_require_admin_accepts_internal_bearer_token(): + from src.utils.internal_auth import resolve_data_manager_service_token + + app = Flask(__name__) + app.secret_key = "test-secret" + + with patch.dict(sys.modules, {"spacy": MagicMock()}): + from src.interfaces.uploader_app.app import FlaskAppWrapper + + dummy_wrapper = type("DummyWrapper", (), {})() + dummy_wrapper.auth_enabled = True + with patch("src.utils.internal_auth.read_secret", side_effect=_secret_reader({ + "PG_PASSWORD": "postgres-password", + })): + dummy_wrapper.api_token, dummy_wrapper.api_token_source = resolve_data_manager_service_token() + + def handler(): + return "ok" + + protected = FlaskAppWrapper.require_admin(dummy_wrapper, handler) + + with app.test_request_context("/", headers={"Authorization": f"Bearer {dummy_wrapper.api_token}"}): + assert protected() == "ok"