diff --git a/README.md b/README.md index 49341f02..c8ce7036 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,16 @@ db: > > Cf. the [API model][docs-models-db] for further options and details. +> **Environment variable overrides:** FOCA supports overriding MongoDB +> connection parameters via environment variables. If the `MONGO_URI` +> environment variable is set, it will be used as the complete MongoDB +> connection string, taking precedence over all other MongoDB-related +> environment variables and configuration file settings. This is useful for +> replica sets, SRV records, TLS options, or URIs from secret managers. If +> `MONGO_URI` is not set, the following individual environment variables can +> override corresponding configuration values: `MONGO_HOST`, `MONGO_PORT`, +> `MONGO_USERNAME`, `MONGO_PASSWORD`, `MONGO_DBNAME`. + ### Configuring exceptions FOCA provides a convenient, configurable exception handler and a simple way diff --git a/foca/database/register_mongodb.py b/foca/database/register_mongodb.py index 4433515d..c66ad01a 100644 --- a/foca/database/register_mongodb.py +++ b/foca/database/register_mongodb.py @@ -114,6 +114,14 @@ def _create_mongo_client( ) -> PyMongo: """Create MongoDB client for Flask application instance. + If the ``MONGO_URI`` environment variable is set and non-empty, it is + used as the complete MongoDB connection string. In that case, the + ``host``, ``port``, and ``db`` parameters as well as individual + environment variables (``MONGO_HOST``, ``MONGO_PORT``, + ``MONGO_USERNAME``, ``MONGO_PASSWORD``, ``MONGO_DBNAME``) are ignored. + A warning is logged if any individual MongoDB environment variables + are also set. + Args: app: Flask application instance. host: Host at which MongoDB database is exposed. @@ -123,6 +131,26 @@ def _create_mongo_client( Returns: MongoDB client for Flask application instance. """ + mongo_uri = os.environ.get('MONGO_URI') + if mongo_uri is not None and mongo_uri != "": + individual_vars = { + 'MONGO_HOST': os.environ.get('MONGO_HOST'), + 'MONGO_PORT': os.environ.get('MONGO_PORT'), + 'MONGO_USERNAME': os.environ.get('MONGO_USERNAME'), + 'MONGO_PASSWORD': os.environ.get('MONGO_PASSWORD'), + 'MONGO_DBNAME': os.environ.get('MONGO_DBNAME'), + } + ignored = [name for name, val in individual_vars.items() if val is not None and val != ""] + if ignored: + logger.warning( + "MONGO_URI is set; ignoring individual MongoDB environment variable(s): %s", + ', '.join(ignored), + ) + app.config['MONGO_URI'] = mongo_uri + mongo = PyMongo(app) + logger.info("Registered MongoDB client via MONGO_URI with Flask application.") + return mongo + auth = '' user = os.environ.get('MONGO_USERNAME') if user is not None and user != "": diff --git a/foca/foca.py b/foca/foca.py index a249c896..ac9e7253 100644 --- a/foca/foca.py +++ b/foca/foca.py @@ -149,6 +149,7 @@ def create_app(self) -> App: mongo_config=self.conf.db, access_control_config=self.conf.security.access_control, ) + logger.info("Access control registered.") else: if ( self.conf.security.access_control.api_specs diff --git a/foca/models/config.py b/foca/models/config.py index 121e2619..5e76b228 100644 --- a/foca/models/config.py +++ b/foca/models/config.py @@ -4,7 +4,7 @@ from enum import Enum from functools import reduce import importlib -from importlib.resources import path as resource_path +from importlib.resources import as_file, files import operator from pathlib import Path from typing import ( @@ -726,10 +726,10 @@ def validate_model_path(cls, v: Optional[str]) -> str: """ if v is None: - with resource_path( - ACCESS_CONTROL_BASE_PATH, + model_file = files(ACCESS_CONTROL_BASE_PATH).joinpath( DEFAULT_MODEL_FILE - ) as _path: + ) + with as_file(model_file) as _path: return str(_path) model_path = Path(v) diff --git a/foca/security/access_control/register_access_control.py b/foca/security/access_control/register_access_control.py index ffd1894a..031a8c40 100644 --- a/foca/security/access_control/register_access_control.py +++ b/foca/security/access_control/register_access_control.py @@ -2,7 +2,7 @@ import logging from functools import wraps -from importlib.resources import path as resource_path +from importlib.resources import as_file, files from pathlib import Path from typing import (Callable, Optional, Tuple) @@ -82,11 +82,13 @@ def register_access_control( mongo_config=mongo_config, access_control_config=access_control_config ) + logger.info("Access control enforcer registered.") cnx_app = register_permission_specs( app=cnx_app, access_control_config=access_control_config ) + logger.info("Access control permission specifications registered.") return cnx_app @@ -108,9 +110,10 @@ def register_permission_specs( """ # Check if default, get package path variables for specs. if access_control_config.api_specs is None: - with resource_path( - ACCESS_CONTROL_BASE_PATH, DEFAULT_API_SPEC_PATH - ) as _path: + spec_file = files(ACCESS_CONTROL_BASE_PATH).joinpath( + DEFAULT_API_SPEC_PATH + ) + with as_file(spec_file) as _path: spec_path = str(_path) else: spec_path = access_control_config.api_specs diff --git a/setup.cfg b/setup.cfg index 4a3889cb..8182f7e5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,3 +18,10 @@ version_variable = foca/__init__.py:__version__ [mypy] ignore_missing_imports = True + +[tool:pytest] +filterwarnings = + ignore:Accessing jsonschema\.draft4_format_checker is deprecated:DeprecationWarning + ignore:jsonschema\.RefResolver is deprecated:DeprecationWarning + ignore:jsonschema\.exceptions\.RefResolutionError is deprecated:DeprecationWarning + ignore:Passing a schema to Validator\.iter_errors is deprecated:DeprecationWarning diff --git a/templates/config.yaml b/templates/config.yaml index 68e29506..71b66ece 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -55,6 +55,13 @@ api: # DATABASE CONFIGURATION # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.DBConfig +# Environment variable overrides: +# MONGO_URI — complete MongoDB connection string (takes precedence over all other vars) +# MONGO_HOST — overrides db.host +# MONGO_PORT — overrides db.port +# MONGO_USERNAME — MongoDB username (enables auth) +# MONGO_PASSWORD — MongoDB password (used with MONGO_USERNAME) +# MONGO_DBNAME — overrides the database name db: host: mongodb port: 27017 diff --git a/tests/database/test_register_mongodb.py b/tests/database/test_register_mongodb.py index e4f340ef..f98a8e5d 100644 --- a/tests/database/test_register_mongodb.py +++ b/tests/database/test_register_mongodb.py @@ -117,3 +117,62 @@ def test_register_mongodb_cust_collections(monkeypatch): conf=MONGO_CONFIG_CUST_COLL, ) assert isinstance(res, MongoConfig) + + +def test__create_mongo_client_with_mongo_uri(monkeypatch): + """When MONGO_URI environment variable is set, it should be used as-is.""" + monkeypatch.setenv("MONGO_URI", "mongodb://myhost:12345/mydb") + app = Flask(__name__) + res = _create_mongo_client(app) + assert isinstance(res, PyMongo) + assert app.config['MONGO_URI'] == "mongodb://myhost:12345/mydb" + + +def test__create_mongo_client_mongo_uri_precedence(monkeypatch): + """When MONGO_URI is set alongside individual vars, MONGO_URI takes precedence.""" + monkeypatch.setenv("MONGO_URI", "mongodb://override:9999/overridedb") + monkeypatch.setenv("MONGO_HOST", "individual_host") + monkeypatch.setenv("MONGO_PORT", "27017") + monkeypatch.setenv("MONGO_DBNAME", "individual_db") + app = Flask(__name__) + res = _create_mongo_client(app) + assert isinstance(res, PyMongo) + assert app.config['MONGO_URI'] == "mongodb://override:9999/overridedb" + + +def test__create_mongo_client_mongo_uri_empty(monkeypatch): + """When MONGO_URI is set to empty string, fall back to component-based URI.""" + monkeypatch.setenv("MONGO_URI", "") + app = Flask(__name__) + res = _create_mongo_client(app) + assert isinstance(res, PyMongo) + assert "mongodb" in app.config['MONGO_URI'] + + +def test__create_mongo_client_mongo_uri_warns(monkeypatch, caplog): + """When MONGO_URI and individual vars are set, a warning should be logged.""" + import logging + monkeypatch.setenv("MONGO_URI", "mongodb://myhost:12345/mydb") + monkeypatch.setenv("MONGO_HOST", "should_be_ignored") + app = Flask(__name__) + with caplog.at_level(logging.WARNING): + _create_mongo_client(app) + assert "MONGO_URI is set" in caplog.text + assert "MONGO_HOST" in caplog.text + + +def test__create_mongo_client_mongo_uri_with_auth(monkeypatch): + """MONGO_URI containing credentials should be used as-is.""" + monkeypatch.setenv("MONGO_URI", "mongodb://user:pass@myhost:12345/mydb") + app = Flask(__name__) + res = _create_mongo_client(app) + assert isinstance(res, PyMongo) + assert app.config['MONGO_URI'] == "mongodb://user:pass@myhost:12345/mydb" + + +def test_register_mongodb_with_mongo_uri(monkeypatch): + """Full registration flow should work when MONGO_URI is set.""" + monkeypatch.setenv("MONGO_URI", "mongodb://testhost:27017/my_db") + app = Flask(__name__) + res = register_mongodb(app=app, conf=MONGO_CONFIG_NO_COLL) + assert isinstance(res, MongoConfig) diff --git a/tests/security/access_control/test_register_access_control.py b/tests/security/access_control/test_register_access_control.py index 376819af..b1593248 100644 --- a/tests/security/access_control/test_register_access_control.py +++ b/tests/security/access_control/test_register_access_control.py @@ -1,5 +1,7 @@ """Tests for registering access control""" +import logging +from types import SimpleNamespace from flask import Flask import mongomock from pymongo import MongoClient @@ -7,7 +9,8 @@ import pytest from foca.security.access_control.register_access_control import ( - check_permissions + check_permissions, + register_access_control, ) from foca.security.access_control.foca_casbin_adapter.adapter import Adapter from foca.errors.exceptions import Forbidden @@ -110,3 +113,39 @@ def mock_func(): ): with pytest.raises(Forbidden): mock_func() + + +def test_register_access_control_logs_setup_steps(monkeypatch, caplog): + """Test setup logs for access control registration flow.""" + logger_name = "foca.security.access_control.register_access_control" + access_control = AccessControlConfig(**ACCESS_CONTROL_CONFIG) + mongo_config = MongoConfig(**MONGO_CONFIG) + + dummy_app = SimpleNamespace( + app=SimpleNamespace(config=SimpleNamespace(foca=SimpleNamespace(db=None))) + ) + + monkeypatch.setattr( + "foca.security.access_control.register_access_control.add_new_database", + lambda app, conf, db_conf, db_name: None, + ) + monkeypatch.setattr( + "foca.security.access_control.register_access_control.register_casbin_enforcer", + lambda app, mongo_config, access_control_config: app, + ) + monkeypatch.setattr( + "foca.security.access_control.register_access_control.register_permission_specs", + lambda app, access_control_config: app, + ) + + with caplog.at_level(logging.INFO, logger=logger_name): + updated_app = register_access_control( + cnx_app=dummy_app, + mongo_config=mongo_config, + access_control_config=access_control, + ) + + assert updated_app is dummy_app + records = [r for r in caplog.records if r.name == logger_name] + assert any("Access control enforcer registered." in r.getMessage() for r in records) + assert any("Access control permission specifications registered." in r.getMessage() for r in records) diff --git a/tests/test_foca.py b/tests/test_foca.py index 2ba7d186..05a332ca 100644 --- a/tests/test_foca.py +++ b/tests/test_foca.py @@ -130,22 +130,29 @@ def test_foca_CORS_disabled(): assert app.app.config.foca.security.cors.enabled is False -def test_foca_invalid_access_control(): +def test_foca_invalid_access_control(capsys): """Ensures access control is not enabled if auth flag is disabled.""" foca = Foca(config_file=INVALID_ACCESS_CONTROL_CONF) app = foca.create_app() + logs = capsys.readouterr().err assert app.app.config.foca.db is None + assert "Please enable security config to register access control." in logs + assert "Access control registered." not in logs -def test_foca_valid_access_control(): +def test_foca_valid_access_control(capsys): """Ensures access control settings are set correctly.""" foca = Foca(config_file=VALID_ACCESS_CONTROL_CONF) app = foca.create_app() + logs = capsys.readouterr().err my_db = app.app.config.foca.db.dbs["test_db"] my_coll = my_db.collections["test_collection"] assert isinstance(my_db.client, Database) assert isinstance(my_coll.client, Collection) assert isinstance(app, App) + assert "Access control enforcer registered." in logs + assert "Access control permission specifications registered." in logs + assert "Access control registered." in logs def test_foca_create_celery_app():