Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions foca/database/register_mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 != "":
Expand Down
1 change: 1 addition & 0 deletions foca/foca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions foca/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 7 additions & 4 deletions foca/security/access_control/register_access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions templates/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions tests/database/test_register_mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
41 changes: 40 additions & 1 deletion tests/security/access_control/test_register_access_control.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Tests for registering access control"""

import logging
from types import SimpleNamespace
from flask import Flask
import mongomock
from pymongo import MongoClient
from unittest import TestCase
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
Expand Down Expand Up @@ -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)
11 changes: 9 additions & 2 deletions tests/test_foca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down