Skip to content
Draft
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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,19 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
- https://github.com/ethyca/fides/labels/high-risk: to indicate that a change is a "high-risk" change that could potentially lead to unanticipated regressions or degradations
- https://github.com/ethyca/fides/labels/db-migration: to indicate that a given change includes a DB migration

## [Unreleased](https://github.com/ethyca/fides/compare/2.85.0..main)
## [Unreleased](https://github.com/ethyca/fides/compare/2.85.1..main)

## [2.85.1](https://github.com/ethyca/fides/compare/2.85.0..2.85.1)

### Added
- Added SecretProvider abstraction and AWS Secrets Manager provider [#8051](https://github.com/ethyca/fides/pull/8051)
- Add DBCredentialProvider for dynamic database credential resolution via AWS Secrets Manager [#8175](https://github.com/ethyca/fides/pull/8175)
- Added configurable pool_recycle setting for database connections [#8209](https://github.com/ethyca/fides/pull/8209)

### Changed
- Route all database connections through DBCredentialProvider for dynamic credential resolution [#8176](https://github.com/ethyca/fides/pull/8176) https://github.com/ethyca/fides/labels/high-risk
- Refactored database engines to use SQLAlchemy creator pattern for per-connection credential resolution [#8148](https://github.com/ethyca/fides/pull/8148)
- Changed the label on API client comments to make it more obvious that they are from the API client and not from the user. [#8220](https://github.com/ethyca/fides/pull/8220)

## [2.85.0](https://github.com/ethyca/fides/compare/2.84.3..2.85.0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ const ActivityTimelineEntry = ({ item }: ActivityTimelineEntryProps) => {
[styles["itemButton--polling"]]: isPolling,
[styles["itemButton--clickable"]]: isClickable,
[styles["itemButton--comment"]]:
type === ActivityTimelineItemTypeEnum.INTERNAL_COMMENT,
type === ActivityTimelineItemTypeEnum.INTERNAL_COMMENT ||
type === ActivityTimelineItemTypeEnum.INTERNAL_AUTOMATION_COMMENT,
[styles["itemButton--manual-task"]]:
type === ActivityTimelineItemTypeEnum.MANUAL_TASK,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ export const usePrivacyRequestComments = (privacyRequestId: string) => {
return {
author,
date: new Date(comment.created_at),
type: ActivityTimelineItemTypeEnum.INTERNAL_COMMENT,
type:
comment.user_id === null && comment.username !== "root_user"
? ActivityTimelineItemTypeEnum.INTERNAL_AUTOMATION_COMMENT
: ActivityTimelineItemTypeEnum.INTERNAL_COMMENT,
showViewLog: false,
description: comment.comment_text,
isError: false,
Expand Down
3 changes: 3 additions & 0 deletions clients/admin-ui/src/features/privacy-requests/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export interface ConfigMessagingSecretsRequest {
export enum ActivityTimelineItemTypeEnum {
REQUEST_UPDATE = "Request update",
INTERNAL_COMMENT = "Internal comment",
INTERNAL_AUTOMATION_COMMENT = "Internal automation comment",
MANUAL_TASK = "Manual task",
}

Expand All @@ -248,6 +249,8 @@ export const TimelineItemColorMap: Record<
> = {
[ActivityTimelineItemTypeEnum.REQUEST_UPDATE]: CUSTOM_TAG_COLOR.DEFAULT,
[ActivityTimelineItemTypeEnum.INTERNAL_COMMENT]: CUSTOM_TAG_COLOR.MARBLE,
[ActivityTimelineItemTypeEnum.INTERNAL_AUTOMATION_COMMENT]:
CUSTOM_TAG_COLOR.MARBLE,
[ActivityTimelineItemTypeEnum.MANUAL_TASK]: CUSTOM_TAG_COLOR.NECTAR,
};

Expand Down
2 changes: 1 addition & 1 deletion design-docs/dynamic-database-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ This depends on a SQLAlchemy internal (`greenlet_spawn`), which is acceptable be
- The planned SQLAlchemy 2.0 upgrade will replace this with the public `async_creator` API.
- The code should include a clear TODO and comments explaining this constraint.

The module-level engines in `ctl_session.py` need to be refactored into lazy factories (similar to how `session_management.py` already works) so the `creator` can be injected at construction time.
The module-level engines in `ctl_session.py` remain as module-level singletons. The `creator` closure captures a provider reference, not credentials themselves — credentials are resolved inside the closure body on every call. This means the engine can be constructed at any time (including module import) and credential rotation still works correctly.

### 4. Automatic Retry on Auth Failure

Expand Down
1 change: 1 addition & 0 deletions noxfiles/ci_nox.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ def pytest_redis_cluster_docker(session: nox.Session) -> None:
TEST_DIRECTORY_COVERAGE = {
"tests/api/": ["api"],
"tests/common/": ["misc-unit"],
"tests/config/": ["misc-unit"],
"tests/ctl/": ["ctl-unit", "ctl-not-external", "ctl-integration", "ctl-external"],
"tests/lib/": ["lib"],
"tests/ops/": [
Expand Down
1 change: 1 addition & 0 deletions noxfiles/setup_tests_nox.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ def pytest_misc_unit(session: Session, pytest_config: PytestConfig) -> None:
"pytest",
*pytest_config.args,
"tests/common/",
"tests/config/",
"tests/service/",
"tests/system_integration_link/",
"tests/task/",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ dev = [
"Faker==14.1.0",
"freezegun==1.5.5",
"GitPython==3.1.41",
"moto[s3]==5.1.22",
"moto[s3,secretsmanager]==5.1.22",
"mypy==1.10.0",
"nox>=2025.11",
"pre-commit==2.20.0",
Expand Down
13 changes: 6 additions & 7 deletions src/fides/api/alembic/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

from alembic import context
from loguru import logger as log
from sqlalchemy import engine_from_config, pool, text
from sqlalchemy import create_engine, pool, text

from fides.api.db.database import include_object
from fides.api.util.logger import setup as setup_fidesapi_logger
from fides.common.engine_creators import SYNC_DIALECT_URL, db_cred_provider, make_sync_creator
from fides.config import CONFIG

# this is the Alembic Config object, which provides
Expand Down Expand Up @@ -44,7 +45,7 @@ def run_migrations_offline():
script output.

"""
url = fides_config.database.sync_database_uri
url = db_cred_provider.get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
Expand All @@ -71,11 +72,9 @@ def run_migrations_online():
and associate a connection with the context.

"""
configuration = alembic_config.get_section(alembic_config.config_ini_section)
configuration["sqlalchemy.url"] = fides_config.database.sync_database_uri
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
connectable = create_engine(
SYNC_DIALECT_URL,
creator=make_sync_creator(),
poolclass=pool.NullPool,
)

Expand Down
6 changes: 4 additions & 2 deletions src/fides/api/app_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
ExceptionHandlers,
response_validation_error_handler,
)
from fides.common.engine_creators import db_cred_provider
from fides.common.session_management import get_api_session, get_autoclose_db_session
from fides.config import CONFIG
from fides.config.config_proxy import ConfigProxy
Expand Down Expand Up @@ -205,12 +206,13 @@ async def run_database_startup(app: FastAPI) -> None:
application webserver.
"""

if not CONFIG.database.sync_database_uri:
database_url = db_cred_provider.get_database_url()
if not database_url:
raise FidesError("No database uri provided")

if CONFIG.database.automigrate:
try:
configure_db(CONFIG.database.sync_database_uri)
configure_db(database_url)
if not CONFIG.test_mode:
with get_autoclose_db_session() as session:
seed_db(session)
Expand Down
43 changes: 19 additions & 24 deletions src/fides/api/db/ctl_session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import ssl
from asyncio import Lock, gather
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
from typing import Any, AsyncGenerator, Callable, Dict
Expand All @@ -11,24 +10,22 @@

from fides.api.db.session import ExtendedSession
from fides.api.db.util import custom_json_deserializer, custom_json_serializer
from fides.common.engine_creators import (
ASYNC_DIALECT_URL,
SYNC_DIALECT_URL,
make_async_creator,
make_sync_creator,
)
from fides.config import CONFIG

# asyncio lock and flag for warming up the async pool
ASYNC_READONLY_POOL_LOCK = Lock()
ASYNC_READONLY_POOL_WARMED = False

# Associated with a workaround in fides.core.config.database_settings
# ref: https://github.com/sqlalchemy/sqlalchemy/discussions/5975
connect_args: Dict[str, Any] = {}
if CONFIG.database.params.get("sslrootcert"):
ssl_ctx = ssl.create_default_context(cafile=CONFIG.database.params["sslrootcert"])
ssl_ctx.verify_mode = ssl.CERT_REQUIRED
connect_args["ssl"] = ssl_ctx

# Parameters are hidden for security
# Primary async engine — credentials resolved per-connection via creator
async_engine = create_async_engine(
CONFIG.database.async_database_uri,
connect_args=connect_args,
ASYNC_DIALECT_URL,
creator=make_async_creator(),
echo=False,
hide_parameters=not CONFIG.dev_mode,
logging_name="AsyncEngine",
Expand All @@ -37,6 +34,9 @@
pool_size=CONFIG.database.api_async_engine_pool_size,
max_overflow=CONFIG.database.api_async_engine_max_overflow,
pool_pre_ping=CONFIG.database.api_async_engine_pool_pre_ping,
pool_recycle=CONFIG.database.pool_recycle
if CONFIG.database.pool_recycle is not None
else -1, # -1 is SQLAlchemy's default (no recycling)
)
async_session_factory = sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
Expand All @@ -49,21 +49,12 @@

if CONFIG.database.async_readonly_database_uri:
logger.info("Creating read-only async engine and session factory")
# Build connect_args for readonly (similar to primary)
readonly_connect_args: Dict[str, Any] = {}
readonly_params = CONFIG.database.readonly_params or {}

if readonly_params.get("sslrootcert"):
ssl_ctx = ssl.create_default_context(cafile=readonly_params["sslrootcert"])
ssl_ctx.verify_mode = ssl.CERT_REQUIRED
readonly_connect_args["ssl"] = ssl_ctx

logger.info(
f"Read-only async settings: max-overflow: {CONFIG.database.api_async_engine_max_overflow}, pool-size: {CONFIG.database.async_readonly_database_pool_size}, pre-warm = {CONFIG.database.async_readonly_database_prewarm}, autocommit = {CONFIG.database.async_readonly_database_autocommit}, skip rollback = {CONFIG.database.async_readonly_database_pool_skip_rollback}"
)
readonly_async_engine = create_async_engine(
CONFIG.database.async_readonly_database_uri,
connect_args=readonly_connect_args,
ASYNC_DIALECT_URL,
creator=make_async_creator(readonly=True),
echo=False,
hide_parameters=not CONFIG.dev_mode,
logging_name="ReadOnlyAsyncEngine",
Expand All @@ -72,6 +63,9 @@
pool_size=CONFIG.database.async_readonly_database_pool_size,
max_overflow=CONFIG.database.async_readonly_database_max_overflow,
pool_pre_ping=CONFIG.database.async_readonly_database_pre_ping,
pool_recycle=CONFIG.database.pool_recycle
if CONFIG.database.pool_recycle is not None
else -1, # -1 is SQLAlchemy's default (no recycling)
# Don't rollback before returning a connection to the pool - this improves performance dramatically;
# can be turned off via config but the default is to not reset on return
pool_reset_on_return=(
Expand All @@ -92,7 +86,8 @@
# and they do not respect engine settings like pool_size, max_overflow, etc.
# these should be removed, and we should standardize on what's provided in `session.py`
sync_engine = create_engine(
CONFIG.database.sync_database_uri,
SYNC_DIALECT_URL,
creator=make_sync_creator(),
echo=False,
hide_parameters=not CONFIG.dev_mode,
logging_name="SyncEngine",
Expand Down
82 changes: 55 additions & 27 deletions src/fides/api/db/session.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Any, Dict
from typing import Any, Callable, Dict, Optional

from loguru import logger
from sqlalchemy import create_engine
Expand All @@ -11,78 +11,106 @@

from fides.api.common_exceptions import MissingConfig
from fides.api.db.util import custom_json_deserializer, custom_json_serializer
from fides.common.engine_creators import SYNC_DIALECT_URL, make_sync_creator
from fides.config import FidesConfig


def get_db_engine(
*,
config: FidesConfig | None = None,
database_uri: str | URL | None = None,
creator: Callable[[], Any] | None = None,
pool_size: int = 50,
max_overflow: int = 50,
keepalives_idle: int | None = None,
keepalives_interval: int | None = None,
keepalives_count: int | None = None,
pool_pre_ping: bool = True,
pool_recycle: Optional[int] = None,
disable_pooling: bool = False,
) -> Engine:
"""Return a database engine.

When *creator* is provided, it is called by the pool to open each new
connection — credentials and connect_args are handled inside the creator.
A dialect-only URL is used for engine construction.

When *database_uri* or *config* is provided, the engine uses a fixed
connection URI (existing behavior).

If the TESTING environment var is set the database engine returned will be
connected to the test DB.
"""
if not config and not database_uri:
raise ValueError("Either a config or database_uri is required")

if not database_uri and config:
# Don't override any database_uri explicitly passed in
if config.test_mode:
database_uri = config.database.sqlalchemy_test_database_uri
else:
database_uri = config.database.sqlalchemy_database_uri

engine_args: Dict[str, Any] = {
"json_serializer": custom_json_serializer,
"json_deserializer": custom_json_deserializer,
}

# keepalives settings
connect_args = {}
if keepalives_idle:
connect_args["keepalives_idle"] = keepalives_idle
if keepalives_interval:
connect_args["keepalives_interval"] = keepalives_interval
if keepalives_count:
connect_args["keepalives_count"] = keepalives_count

if connect_args:
connect_args["keepalives"] = 1
engine_args["connect_args"] = connect_args
if creator:
# Creator handles credentials and connect_args internally,
# so creator needs to set keepalives settings.
if database_uri or config:
raise ValueError(
"database_uri/config cannot be used with creator — "
"the creator handles connection construction"
)
if keepalives_idle or keepalives_interval or keepalives_count:
raise ValueError(
"keepalives_idle/interval/count cannot be used with creator — "
"pass them as connect_args to the creator instead"
)
engine_args["creator"] = creator
database_uri = SYNC_DIALECT_URL
else:
# URI-based path.
if not config and not database_uri:
raise ValueError("Either a config, database_uri, or creator is required")

if not database_uri and config:
if config.test_mode:
database_uri = config.database.sqlalchemy_test_database_uri
else:
database_uri = config.database.sqlalchemy_database_uri

# keepalives settings (only for URI path; creator handles its own)
connect_args = {}
if keepalives_idle:
connect_args["keepalives_idle"] = keepalives_idle
if keepalives_interval:
connect_args["keepalives_interval"] = keepalives_interval
if keepalives_count:
connect_args["keepalives_count"] = keepalives_count

if connect_args:
connect_args["keepalives"] = 1
engine_args["connect_args"] = connect_args

if disable_pooling:
engine_args["poolclass"] = NullPool
else:
engine_args["pool_pre_ping"] = pool_pre_ping
engine_args["pool_size"] = pool_size
engine_args["max_overflow"] = max_overflow
if pool_recycle is not None:
engine_args["pool_recycle"] = pool_recycle

return create_engine(database_uri, **engine_args)


def get_db_session(
config: FidesConfig,
config: FidesConfig, # TODO: remove — no longer used, all callers pass CONFIG
autocommit: bool = False,
autoflush: bool = False,
engine: Engine | None = None,
) -> sessionmaker:
"""Return a database SessionLocal."""
if not config.database.sqlalchemy_database_uri:
raise MissingConfig("No database uri available in the config")
if engine is None:
engine = get_db_engine(creator=make_sync_creator())

return sessionmaker(
autocommit=autocommit,
autoflush=autoflush,
bind=engine or get_db_engine(config=config),
bind=engine,
class_=ExtendedSession,
)

Expand Down
Loading
Loading