Skip to content

Commit f6ed3ee

Browse files
authored
Extending read-only DB configuration (#7139)
1 parent dbe1678 commit f6ed3ee

File tree

5 files changed

+314
-16
lines changed

5 files changed

+314
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
3535
- Increasing default async DB pool size to 50 pooled connections and 50 overflow connections [#7126](https://github.com/ethyca/fides/pull/7126)
3636
- Track active taxonomy in URL in taxonomy screen [#7113](https://github.com/ethyca/fides/pull/7113)
3737
- Updated status labels for the Action Center [#7098](https://github.com/ethyca/fides/pull/7098)
38+
- Extending read-only DB configuration [#7139](https://github.com/ethyca/fides/pull/7139)
3839

3940
### Developer Experience
4041
- Migrated consent settings tables to Ant Design [#7084](https://github.com/ethyca/fides/pull/7084)

src/fides/api/api/deps.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ def get_autoclose_db_session() -> Generator[Session, None, None]:
5656
db.close()
5757

5858

59+
@contextmanager
60+
def get_readonly_autoclose_db_session() -> Generator[Session, None, None]:
61+
"""
62+
Return a read-only database session as a context manager that automatically closes.
63+
Falls back to primary database session if read-only is not configured.
64+
65+
Use this when you need manual control over the session lifecycle outside of API endpoints.
66+
"""
67+
if not CONFIG.database.sqlalchemy_readonly_database_uri:
68+
# If read-only not configured, use primary session
69+
with get_autoclose_db_session() as db:
70+
yield db
71+
return
72+
73+
# Create read-only session using existing get_readonly_api_session
74+
try:
75+
db = get_readonly_api_session()
76+
yield db
77+
finally:
78+
db.close()
79+
80+
5981
def get_api_session() -> Session:
6082
"""Gets the shared database session to use for API functionality"""
6183
global _engine # pylint: disable=W0603

src/fides/api/db/ctl_session.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ssl
2-
from typing import Any, AsyncGenerator, Dict
2+
from typing import Any, AsyncGenerator, Callable, Dict
33

44
from sqlalchemy import create_engine
55
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@@ -41,6 +41,53 @@
4141
)
4242
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
4343

44+
# Read-only async engine and session
45+
# Only created if read-only database URI is configured
46+
readonly_async_engine: Any = None
47+
readonly_async_session: Callable[[], AsyncSession]
48+
49+
# Initialize readonly_async_session (will be overridden if readonly DB is configured)
50+
readonly_async_session = async_session
51+
52+
if CONFIG.database.async_readonly_database_uri:
53+
# Build connect_args for readonly (similar to primary)
54+
readonly_connect_args: Dict[str, Any] = {}
55+
readonly_params = CONFIG.database.readonly_params or {}
56+
57+
if readonly_params.get("sslrootcert"):
58+
ssl_ctx = ssl.create_default_context(cafile=readonly_params["sslrootcert"])
59+
ssl_ctx.verify_mode = ssl.CERT_REQUIRED
60+
readonly_connect_args["ssl"] = ssl_ctx
61+
62+
if CONFIG.database.api_async_engine_keepalives_idle:
63+
readonly_connect_args["keepalives_idle"] = (
64+
CONFIG.database.api_async_engine_keepalives_idle
65+
)
66+
if CONFIG.database.api_async_engine_keepalives_interval:
67+
readonly_connect_args["keepalives_interval"] = (
68+
CONFIG.database.api_async_engine_keepalives_interval
69+
)
70+
if CONFIG.database.api_async_engine_keepalives_count:
71+
readonly_connect_args["keepalives_count"] = (
72+
CONFIG.database.api_async_engine_keepalives_count
73+
)
74+
75+
readonly_async_engine = create_async_engine(
76+
CONFIG.database.async_readonly_database_uri,
77+
connect_args=readonly_connect_args,
78+
echo=False,
79+
hide_parameters=not CONFIG.dev_mode,
80+
logging_name="ReadOnlyAsyncEngine",
81+
json_serializer=custom_json_serializer,
82+
json_deserializer=custom_json_deserializer,
83+
pool_size=CONFIG.database.api_async_engine_pool_size,
84+
max_overflow=CONFIG.database.api_async_engine_max_overflow,
85+
pool_pre_ping=CONFIG.database.api_async_engine_pool_pre_ping,
86+
)
87+
readonly_async_session = sessionmaker(
88+
readonly_async_engine, class_=AsyncSession, expire_on_commit=False
89+
)
90+
4491
# TODO: this engine and session are only used in test modules,
4592
# and they do not respect engine settings like pool_size, max_overflow, etc.
4693
# these should be removed, and we should standardize on what's provided in `session.py`

src/fides/config/database_settings.py

Lines changed: 116 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from typing import Dict, Optional, cast
77
from urllib.parse import quote, quote_plus, urlencode
88

9-
from pydantic import Field, PostgresDsn, ValidationInfo, field_validator
9+
from pydantic import (
10+
Field,
11+
PostgresDsn,
12+
ValidationInfo,
13+
field_validator,
14+
model_validator,
15+
)
1016
from pydantic_settings import SettingsConfigDict
1117

1218
from fides.config.utils import get_test_mode
@@ -101,12 +107,26 @@ class DatabaseSettings(FidesSettings):
101107
default=None,
102108
description="The hostname of the application read database server.",
103109
)
104-
105-
# TODO (LJ-663): add optional readonly_user
106-
# TODO (LJ-663): add optional readonly_password
107-
# TODO (LJ-663): add optional readonly_port
108-
# TODO (LJ-663): add optional readonly_params
109-
# TODO (LJ-663): add optional readonly_db
110+
readonly_user: Optional[str] = Field(
111+
default=None,
112+
description="The database user for read-only database connections. If not provided and readonly_server is set, uses 'user'.",
113+
)
114+
readonly_password: Optional[str] = Field(
115+
default=None,
116+
description="The password for read-only database connections. If not provided and readonly_server is set, uses 'password'.",
117+
)
118+
readonly_port: Optional[str] = Field(
119+
default=None,
120+
description="The port for read-only database connections. If not provided and readonly_server is set, uses 'port'.",
121+
)
122+
readonly_db: Optional[str] = Field(
123+
default=None,
124+
description="The database name for read-only database connections. If not provided and readonly_server is set, uses 'db'.",
125+
)
126+
readonly_params: Dict = Field(
127+
default={},
128+
description="Additional connection parameters for read-only database connections. If not provided and readonly_server is set, uses 'params'.",
129+
)
110130

111131
task_engine_pool_size: int = Field(
112132
default=50,
@@ -172,6 +192,11 @@ class DatabaseSettings(FidesSettings):
172192
description="Programmatically created synchronous connection string for the configured database (either application or test).",
173193
exclude=True,
174194
)
195+
async_readonly_database_uri: Optional[str] = Field(
196+
default=None,
197+
description="Programmatically created asynchronous connection string for the read-only application database.",
198+
exclude=True,
199+
)
175200

176201
@field_validator("password", mode="before")
177202
@classmethod
@@ -181,6 +206,39 @@ def escape_password(cls, value: Optional[str]) -> Optional[str]:
181206
return quote_plus(value)
182207
return value
183208

209+
@model_validator(mode="before")
210+
@classmethod
211+
def resolve_readonly_fields(cls, values: Dict) -> Dict:
212+
"""
213+
If readonly_server is set but readonly fields are not provided,
214+
fall back to primary database values.
215+
"""
216+
if values.get("readonly_server"):
217+
# Fall back to primary user if readonly_user not provided
218+
if values.get("readonly_user") is None:
219+
values["readonly_user"] = values.get("user")
220+
221+
# Fall back to primary password if readonly_password not provided
222+
if values.get("readonly_password") is None:
223+
values["readonly_password"] = values.get("password")
224+
# If readonly_password was provided directly, escape it
225+
elif isinstance(values.get("readonly_password"), str):
226+
values["readonly_password"] = quote_plus(values["readonly_password"])
227+
228+
# Fall back to primary port if readonly_port not provided
229+
if values.get("readonly_port") is None:
230+
values["readonly_port"] = values.get("port")
231+
232+
# Fall back to primary db if readonly_db not provided
233+
if values.get("readonly_db") is None:
234+
values["readonly_db"] = values.get("db")
235+
236+
# Fall back to primary params if readonly_params not provided
237+
if not values.get("readonly_params"):
238+
values["readonly_params"] = values.get("params", {})
239+
240+
return values
241+
184242
@field_validator("sync_database_uri", mode="before")
185243
@classmethod
186244
def assemble_sync_database_uri(
@@ -281,25 +339,68 @@ def assemble_readonly_db_connection(
281339
if not info.data.get("readonly_server"):
282340
return None
283341
port: int = port_integer_converter(info)
342+
readonly_port: int = (
343+
port_integer_converter(info, "readonly_port")
344+
if info.data.get("readonly_port")
345+
else port
346+
)
284347
return str(
285-
# TODO: support optional readonly params for user, password, etc.
286348
PostgresDsn.build( # pylint: disable=no-member
287-
scheme="postgresql",
288-
username=info.data.get("user"),
289-
password=info.data.get("password"),
349+
scheme="postgresql+psycopg2",
350+
username=info.data.get("readonly_user") or info.data.get("user"),
351+
password=info.data.get("readonly_password")
352+
or info.data.get("password"),
290353
host=info.data.get("readonly_server"),
291-
port=port,
292-
path=f"{info.data.get('db') or ''}",
354+
port=readonly_port,
355+
path=f"{info.data.get('readonly_db') or info.data.get('db') or ''}",
293356
query=(
294357
urlencode(
295-
cast(Dict, info.data.get("params")), quote_via=quote, safe="/"
358+
cast(Dict, info.data.get("readonly_params")),
359+
quote_via=quote,
360+
safe="/",
296361
)
297-
if info.data.get("params")
362+
if info.data.get("readonly_params")
298363
else None
299364
),
300365
)
301366
)
302367

368+
@field_validator("async_readonly_database_uri", mode="before")
369+
@classmethod
370+
def assemble_async_readonly_db_connection(
371+
cls, v: Optional[str], info: ValidationInfo
372+
) -> Optional[str]:
373+
"""Join DB connection credentials into an async read-only connection string."""
374+
if isinstance(v, str) and v:
375+
return v
376+
if not info.data.get("readonly_server"):
377+
return None
378+
379+
# Handle SSL params for asyncpg (same as async_database_uri)
380+
params = cast(Dict, deepcopy(info.data.get("readonly_params", {})))
381+
if "sslmode" in params:
382+
params["ssl"] = params.pop("sslmode")
383+
params.pop("sslrootcert", None)
384+
385+
port: int = port_integer_converter(info)
386+
readonly_port: int = (
387+
port_integer_converter(info, "readonly_port")
388+
if info.data.get("readonly_port")
389+
else port
390+
)
391+
return str(
392+
PostgresDsn.build( # pylint: disable=no-member
393+
scheme="postgresql+asyncpg",
394+
username=info.data.get("readonly_user") or info.data.get("user"),
395+
password=info.data.get("readonly_password")
396+
or info.data.get("password"),
397+
host=info.data.get("readonly_server"),
398+
port=readonly_port,
399+
path=f"{info.data.get('readonly_db') or info.data.get('db') or ''}",
400+
query=urlencode(params, quote_via=quote, safe="/") if params else None,
401+
)
402+
)
403+
303404
@field_validator("sqlalchemy_test_database_uri", mode="before")
304405
@classmethod
305406
def assemble_test_db_connection(cls, v: Optional[str], info: ValidationInfo) -> str:

0 commit comments

Comments
 (0)