Skip to content

ENG-2857 Use callable for encryption key#7588

Merged
erosselli merged 8 commits intomainfrom
erosselli/2857
Mar 9, 2026
Merged

ENG-2857 Use callable for encryption key#7588
erosselli merged 8 commits intomainfrom
erosselli/2857

Conversation

@erosselli
Copy link
Contributor

@erosselli erosselli commented Mar 6, 2026

Ticket ENG-2857

Description Of Changes

This is a pure refactor (zero behavior change) that centralizes all StringEncryptedType column definitions behind a single encrypted_type() factory function and switches from a string key captured at import time to a callable key resolved at query time. This will enable future changes as part of our migration to an envelope encryption strategy.

Previously, ~36 StringEncryptedType(...) calls across 16 model files each independently referenced CONFIG.security.app_encryption_key as a string literal at import time. This PR:

  1. Introduces fides/api/db/encryption_utils.py with:
    • get_encryption_key() — returns CONFIG.security.app_encryption_key (cached for process lifetime)
    • encrypted_type(type_in=...) — builds a StringEncryptedType using get_encryption_key as a callable key
  2. Replaces all inline StringEncryptedType(...) constructions in model files with encrypted_type(...)
  3. Updates optionally_encrypted_type() in db/util.py to delegate to encrypted_type()
  4. Updates aes_gcm_encryption_util.py to use get_encryption_key() instead of direct CONFIG access
  5. Updates consent_encryption_migration.py to use encrypted_type() for its encryptor

sqlalchemy-utils natively supports callable keys — if key is callable, it invokes it on every encrypt/decrypt via _update_key(). This means encryption/decryption behavior is identical; the only difference is when the key is resolved (query time vs import time).

registration.py is excluded as it will be removed entirely in a follow-up.

Code Changes

  • New src/fides/api/db/encryption_utils.pyget_encryption_key() and encrypted_type() factory
  • Updated 16 model files to use encrypted_type() instead of inline StringEncryptedType(...)
  • Updated db/util.pyoptionally_encrypted_type() now delegates to encrypted_type()
  • Updated aes_gcm_encryption_util.py — uses get_encryption_key() for key resolution
  • Updated consent_encryption_migration.py — uses encrypted_type() for encryptor construction
  • Updated privacy_request/webhook.py — uses get_encryption_key() for JWE generation
  • New tests/api/db/test_encryption_utils.py — unit tests for the new module
  • New changelog/7588-encrypted-type-factory.yaml

Steps to Confirm

  1. all tests should pass
  2. Make sure you have things in your database before pulling down the branch
  3. Click around the Admin UI, everything should work as usual

Pre-Merge Checklist

  • Issue requirements met
  • All CI pipelines succeeded
  • CHANGELOG.md updated
    • Add a db-migration This indicates that a change includes a database migration label to the entry if your change includes a DB migration
    • Add a high-risk This issue suggests changes that have a high-probability of breaking existing code label to the entry if your change includes a high-risk change (i.e. potential for performance impact or unexpected regression) that should be flagged
    • Updates unreleased work already in Changelog, no new entry necessary
  • UX feedback:
    • All UX related changes have been reviewed by a designer
    • No UX review needed
  • Followup issues:
    • Followup issues created
    • No followup issues
  • Database migrations:
    • Ensure that your downrev is up to date with the latest revision on main
    • Ensure that your downgrade() migration is correct and works
      • If a downgrade migration is not possible for this change, please call this out in the PR description!
    • No migrations
  • Documentation:
    • Documentation complete, PR opened in fidesdocs
    • Documentation issue created in fidesdocs
    • If there are any new client scopes created as part of the pull request, remember to update public-facing documentation that references our scope registry
    • No documentation updates required

Summary by CodeRabbit

  • Refactor
    • Centralized encryption across the database and utilities so encryption keys are sourced dynamically and applied consistently; no change to external behavior or APIs.
  • Tests
    • Added unit tests for the new encryption utility, key caching behavior, and encrypted-type construction.
  • Chores
    • Added a changelog entry documenting the change.

@vercel
Copy link
Contributor

vercel bot commented Mar 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fides-plus-nightly Ready Ready Preview, Comment Mar 9, 2026 5:59pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
fides-privacy-center Ignored Ignored Mar 9, 2026 5:59pm

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralizes encryption by adding an encryption_utils module (DEK cache, get_encryption_key, encrypted_type, cache reset), and refactors many ORM models and utilities to use encrypted_type()/get_encryption_key() instead of inline StringEncryptedType/AesGcmEngine/CONFIG usage.

Changes

Cohort / File(s) Summary
Core Encryption Utility
src/fides/api/db/encryption_utils.py, changelog/7588-encrypted-type-factory.yaml
Adds process-wide DEK cache, get_encryption_key() (callable key provider), _reset_encryption_key_cache() for tests, and encrypted_type(type_in=...) factory returning a StringEncryptedType configured to use a callable key and AesGcmEngine + pkcs5.
Utilities & Key Consumers
src/fides/api/db/util.py, src/fides/api/util/encryption/aes_gcm_encryption_util.py, src/fides/api/util/consent_encryption_migration.py, src/fides/api/models/privacy_request/webhook.py
Replace direct CONFIG key usage with get_encryption_key() and switch inline StringEncryptedType/AesGcmEngine construction to encrypted_type() or dynamic key retrieval.
ORM — JSON/dict encrypted columns
src/fides/api/models/.../connectionconfig.py, src/fides/api/models/application_config.py, src/fides/api/models/identity_salt.py, src/fides/api/models/masking_secret.py, src/fides/api/models/messaging.py, src/fides/api/models/privacy_request/.../*.py, src/fides/api/models/storage.py, src/fides/api/models/policy/policy.py, src/fides/api/models/sql_models.py
Replaces MutableDict.as_mutable(StringEncryptedType(JSONTypeOverride, CONFIG..., AesGcmEngine, "pkcs5")) with MutableDict.as_mutable(encrypted_type(type_in=JSONTypeOverride)); updates imports.
ORM — String encrypted columns
src/fides/api/models/chat_config.py, src/fides/api/models/connection_oauth_credentials.py, src/fides/api/models/fides_user.py, src/fides/api/models/openid_provider.py, src/fides/api/models/privacy_preference.py
Replaces StringEncryptedType(type_in=String(), key=CONFIG..., engine=AesGcmEngine, padding="pkcs5") with encrypted_type(type_in=String()); removes direct CONFIG/AesGcmEngine/StringEncryptedType imports.
Tests
tests/api/db/test_encryption_utils.py
Adds tests for get_encryption_key() (caching/reset semantics) and encrypted_type() (return type, default underlying type, callable key, engine).

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,200,255,0.5)
    participant App as Application / ORM usage
    end
    rect rgba(200,255,200,0.5)
    participant Model as ORM Model / Column
    end
    rect rgba(255,200,200,0.5)
    participant EncUtil as encryption_utils.encrypted_type
    end
    rect rgba(255,255,200,0.5)
    participant KeyProv as encryption_utils.get_encryption_key
    end

    App->>Model: read/write encrypted column
    Model->>EncUtil: delegate (encrypted_type) for encrypt/decrypt
    EncUtil->>KeyProv: call to obtain DEK (callable key)
    KeyProv-->>EncUtil: return DEK
    EncUtil-->>Model: perform AES-GCM encrypt/decrypt using DEK
    Model-->>App: return plaintext / persist ciphertext
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

Suggested reviewers

  • johnewart
  • adamsachs

Poem

🐰 I tunneled through configs, keys in a row,
One factory now tells all secrets how to go.
Cached key hums softly while AesGcm sings,
Models call one source — safe nesting of things.
Hop — encrypted fields rest, snug as spring.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ENG-2857 Use callable for encryption key' clearly and specifically summarizes the main change: switching from a string key to a callable key for encryption.
Description check ✅ Passed The description comprehensively covers all major sections including context, implementation details, code changes, and confirmation steps. All template sections are addressed with either substantive content or explicit checkbox selections.
Docstring Coverage ✅ Passed Docstring coverage is 95.65% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch erosselli/2857

Comment @coderabbitai help to get the list of available commands and usage tips.

@erosselli erosselli marked this pull request as ready for review March 9, 2026 15:33
@erosselli erosselli requested a review from a team as a code owner March 9, 2026 15:33
@erosselli erosselli requested review from JadeCara and johnewart and removed request for a team March 9, 2026 15:33
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR is a clean, mechanical refactor that centralises all StringEncryptedType column definitions behind a new encrypted_type() factory in src/fides/api/db/encryption_utils.py and switches from a string key resolved at import time to a callable key resolved at query time. This is a well-motivated stepping-stone toward future envelope-encryption support.

Key observations:

  • The refactor is consistent across all 16 model files and all utility call-sites; no inline StringEncryptedType constructions were left behind.
  • sqlalchemy-utils natively supports callable keys (_update_key(key()) path), so the encrypt/decrypt behaviour is unchanged.
  • The new test file covers key retrieval, caching, type inspection, and engine verification with proper per-test cache isolation via an autouse fixture.
  • PR size rule violation: This PR touches 23 files, which exceeds the 15-file limit defined in the team's custom rule. While the changes are uniform and low-risk, this should be noted.
  • The get_encryption_key() cache is intentional for the current "legacy mode" but silently prevents runtime key rotation once the cache is warm. A clarifying comment would help future developers avoid accidentally relying on the cache when key-rotation support is added (see inline comment).

Confidence Score: 4/5

  • This PR is safe to merge; it is a pure refactor with no behaviour change and solid test coverage.
  • All changes are mechanical substitutions of a well-understood pattern, the callable-key path in sqlalchemy-utils is documented and tested, and the new test suite provides good unit coverage. The only non-trivial concern is that the process-lifetime cache could silently suppress future key-rotation logic — but that is a known limitation acknowledged in the PR description rather than an immediate bug.
  • src/fides/api/db/encryption_utils.py — the caching semantics of get_encryption_key() should be clearly documented as a limitation before envelope-encryption key-rotation is wired in.

Important Files Changed

Filename Overview
src/fides/api/db/encryption_utils.py New module introducing get_encryption_key() (with process-lifetime caching) and encrypted_type() factory. Caching is intentional per PR description, and the callable key pattern is correctly wired. Minor concern: if CONFIG key is None on first call, the None value is never cached (check passes), so each call retries CONFIG — correct behavior but return type annotation of -> str could be misleading.
tests/api/db/test_encryption_utils.py Good unit test coverage for the new module; tests key retrieval, caching, type inspection, and engine selection. The autouse reset_cache fixture ensures test isolation.
src/fides/api/db/util.py optionally_encrypted_type() now correctly delegates to encrypted_type(); dead imports (AesGcmEngine, StringEncryptedType, CONFIG) were cleaned up.
src/fides/api/models/privacy_request/webhook.py Four JWE generation calls updated to use get_encryption_key() instead of direct CONFIG access. Change is purely mechanical and correct.
src/fides/api/util/consent_encryption_migration.py _make_encryptor() now delegates to encrypted_type(). Callable key resolves correctly on each process_bind_param/process_result_value call. CONFIG import retained — still needed for dev_mode guard.
src/fides/api/util/encryption/aes_gcm_encryption_util.py encrypt_with_sqlalchemy_utils, decrypt_with_sqlalchemy_utils, and _get_sqlalchemy_compatible_key updated to use get_encryption_key(). CONFIG import retained for encoding config. Changes are minimal and correct.
src/fides/api/models/connectionconfig.py Straightforward replacement of inline StringEncryptedType with encrypted_type() import.
src/fides/api/models/privacy_request/privacy_request.py Straightforward replacement of inline StringEncryptedType with encrypted_type() import.
changelog/7588-encrypted-type-factory.yaml Changelog entry is present and correctly categorized as "Changed".

Last reviewed commit: 2270927

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/api/db/test_encryption_utils.py (1)

71-74: Consider adding a test for padding configuration.

The encrypted_type() factory sets padding="pkcs5". For completeness, you might want to verify this configuration is correctly applied, similar to the engine test.

💡 Optional test for padding
def test_uses_pkcs5_padding(self):
    """Padding should be pkcs5."""
    col_type = encrypted_type()
    assert col_type.padding == "pkcs5"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api/db/test_encryption_utils.py` around lines 71 - 74, Add an assertion
to verify the padding configuration on the produced ColumnType: call
encrypted_type() in the test (same place as test_uses_aesgcm_engine) and assert
that the returned object's padding attribute equals "pkcs5" (e.g., within a new
test_uses_pkcs5_padding or by extending test_uses_aesgcm_engine to include this
check) so the factory's padding setting is explicitly validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fides/api/db/encryption_utils.py`:
- Around line 12-28: The current implementation freezes the DEK by caching
CONFIG.security.app_encryption_key in the module-level _cached_dek inside
get_encryption_key(), causing mid-process rotations to be ignored; remove the
global caching so get_encryption_key() always resolves and returns
CONFIG.security.app_encryption_key at call time (and delete or repurpose
_cached_dek and _reset_encryption_key_cache), or alternatively move caching
responsibility into the ORM type factory only (so only that factory uses a local
cached value) while keeping get_encryption_key() uncached for direct callers
like aes_gcm_encryption_util.py and webhook.py.

---

Nitpick comments:
In `@tests/api/db/test_encryption_utils.py`:
- Around line 71-74: Add an assertion to verify the padding configuration on the
produced ColumnType: call encrypted_type() in the test (same place as
test_uses_aesgcm_engine) and assert that the returned object's padding attribute
equals "pkcs5" (e.g., within a new test_uses_pkcs5_padding or by extending
test_uses_aesgcm_engine to include this check) so the factory's padding setting
is explicitly validated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b4e9c9a-1c2b-4fd9-b6fd-831ea1ae0247

📥 Commits

Reviewing files that changed from the base of the PR and between 8e40b98 and 2270927.

📒 Files selected for processing (23)
  • changelog/7588-encrypted-type-factory.yaml
  • src/fides/api/db/encryption_utils.py
  • src/fides/api/db/util.py
  • src/fides/api/models/application_config.py
  • src/fides/api/models/chat_config.py
  • src/fides/api/models/connection_oauth_credentials.py
  • src/fides/api/models/connectionconfig.py
  • src/fides/api/models/fides_user.py
  • src/fides/api/models/identity_salt.py
  • src/fides/api/models/masking_secret.py
  • src/fides/api/models/messaging.py
  • src/fides/api/models/openid_provider.py
  • src/fides/api/models/policy/policy.py
  • src/fides/api/models/privacy_preference.py
  • src/fides/api/models/privacy_request/privacy_request.py
  • src/fides/api/models/privacy_request/provided_identity.py
  • src/fides/api/models/privacy_request/request_task.py
  • src/fides/api/models/privacy_request/webhook.py
  • src/fides/api/models/sql_models.py
  • src/fides/api/models/storage.py
  • src/fides/api/util/consent_encryption_migration.py
  • src/fides/api/util/encryption/aes_gcm_encryption_util.py
  • tests/api/db/test_encryption_utils.py

Comment on lines +12 to +28
_cached_dek: str | None = None


def get_encryption_key() -> str:
"""Return the DEK. In legacy mode, this is CONFIG.security.app_encryption_key.
Cached for the lifetime of the process (called on every encrypt/decrypt)."""
global _cached_dek
if _cached_dek is not None:
return _cached_dek
_cached_dek = CONFIG.security.app_encryption_key
return _cached_dek


def _reset_encryption_key_cache() -> None:
"""Reset the cached DEK. For testing only."""
global _cached_dek
_cached_dek = None
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't freeze the encryption key after first use.

get_encryption_key() caches CONFIG.security.app_encryption_key in _cached_dek, so after the first encrypt/decrypt in a worker the key is no longer resolved at use time. That undermines the callable-key goal of this refactor and also changes the new direct callers in src/fides/api/util/encryption/aes_gcm_encryption_util.py and src/fides/api/models/privacy_request/webhook.py: a mid-process key override/rotation will keep using the stale value until restart. Please either remove the cache here or scope the cached path to the ORM type factory only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/db/encryption_utils.py` around lines 12 - 28, The current
implementation freezes the DEK by caching CONFIG.security.app_encryption_key in
the module-level _cached_dek inside get_encryption_key(), causing mid-process
rotations to be ignored; remove the global caching so get_encryption_key()
always resolves and returns CONFIG.security.app_encryption_key at call time (and
delete or repurpose _cached_dek and _reset_encryption_key_cache), or
alternatively move caching responsibility into the ORM type factory only (so
only that factory uses a local cached value) while keeping get_encryption_key()
uncached for direct callers like aes_gcm_encryption_util.py and webhook.py.

Copy link
Contributor

@JadeCara JadeCara left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔑 One Key At A Time

thirty-six scattered
keys converge to one factory
callable, not called

Overall looks really solid! The only test gap I saw was maybe on get_encryption_key if config key is empty. The cache guard if _cached_dek is not None means an empty string "" would be cached and reused. This is probably fine (CONFIG validation should prevent it), but a test asserting behavior with an empty/blank key would make the contract explicit — especially since this is the single source of truth for encryption keys going forward.

Return the encryption key set in CONFIG.security.app_encryption_key.
Cached for the lifetime of the process (called on every encrypt/decrypt).

TODO: implement support for other key managers like AWS KMS
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everytime I see a TODO (sorry) - I have to ask, do we have this ticketed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes we do! we have a whole epic for the encryption changes :)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fides/api/db/encryption_utils.py`:
- Around line 23-33: The caching logic in encryption_utils.py incorrectly
conflates "not cached yet" with a cached value of None/empty; change the
sentinel for _cached_dek to a unique object (e.g., _UNSET = object()) and
initialize _cached_dek = _UNSET, then in the getter check "if _cached_dek is not
_UNSET: return _cached_dek", set _cached_dek =
CONFIG.security.app_encryption_key (keeping the global declaration), and update
the warning to trigger when the cached value is falsy (e.g., if _cached_dek in
(None, "") or if not _cached_dek) so a None config is cached once and the
warning is emitted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 434a4b37-adce-4608-a742-5db203569019

📥 Commits

Reviewing files that changed from the base of the PR and between eebec4b and 007e68e.

📒 Files selected for processing (1)
  • src/fides/api/db/encryption_utils.py

Comment on lines +23 to +33
global _cached_dek
if _cached_dek is not None:
return _cached_dek

_cached_dek = CONFIG.security.app_encryption_key
if _cached_dek == "":
logger.warning(
"App encryption key is empty, this may lead to unexpected issues"
)

return _cached_dek
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cache is ineffective and warning is skipped when encryption key is None.

If CONFIG.security.app_encryption_key returns None:

  1. The is not None check on line 24 passes (because _cached_dek starts as None)
  2. _cached_dek gets assigned None from config
  3. The warning check == "" doesn't match None
  4. On subsequent calls, line 24's check fails again since None is not None is False

This causes repeated config reads and no warning for a None key. Consider using a sentinel value to distinguish "not yet cached" from "cached as None/empty".

🔧 Proposed fix using a sentinel value
+_NOT_SET = object()  # sentinel to distinguish "not cached" from "cached as None"
-_cached_dek: str | None = None
+_cached_dek: str | None | object = _NOT_SET


 def get_encryption_key() -> str:
     """
     Return the encryption key set in CONFIG.security.app_encryption_key.
     Cached for the lifetime of the process (called on every encrypt/decrypt).

     TODO: implement support for other key managers like AWS KMS
     """
     global _cached_dek
-    if _cached_dek is not None:
+    if _cached_dek is not _NOT_SET:
         return _cached_dek

     _cached_dek = CONFIG.security.app_encryption_key
-    if _cached_dek == "":
+    if not _cached_dek:  # catches both None and empty string
         logger.warning(
             "App encryption key is empty, this may lead to unexpected issues"
         )

     return _cached_dek
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/db/encryption_utils.py` around lines 23 - 33, The caching logic
in encryption_utils.py incorrectly conflates "not cached yet" with a cached
value of None/empty; change the sentinel for _cached_dek to a unique object
(e.g., _UNSET = object()) and initialize _cached_dek = _UNSET, then in the
getter check "if _cached_dek is not _UNSET: return _cached_dek", set _cached_dek
= CONFIG.security.app_encryption_key (keeping the global declaration), and
update the warning to trigger when the cached value is falsy (e.g., if
_cached_dek in (None, "") or if not _cached_dek) so a None config is cached once
and the warning is emitted.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/fides/api/db/encryption_utils.py (1)

13-33: ⚠️ Potential issue | 🟡 Minor

Cache is ineffective when encryption key is None.

The caching logic cannot distinguish between "not cached yet" and "cached as None". If CONFIG.security.app_encryption_key returns None:

  1. _cached_dek is not None (line 24) evaluates to False
  2. _cached_dek gets assigned None from config
  3. On subsequent calls, line 24's check still fails → repeated config reads

Additionally, the return type annotation -> str is incorrect since the function can return None.

🔧 Proposed fix using a sentinel value
+_NOT_SET = object()  # sentinel to distinguish "not cached" from "cached as None"
-_cached_dek: str | None = None
+_cached_dek: str | None | object = _NOT_SET


 def get_encryption_key() -> str | None:
     """
     Return the encryption key set in CONFIG.security.app_encryption_key.
     Cached for the lifetime of the process (called on every encrypt/decrypt).

     TODO: implement support for other key managers like AWS KMS
     """
     global _cached_dek
-    if _cached_dek is not None:
+    if _cached_dek is not _NOT_SET:
         return _cached_dek

     _cached_dek = CONFIG.security.app_encryption_key
     if not _cached_dek:
         logger.warning(
             "App encryption key is empty, this may lead to unexpected issues"
         )

     return _cached_dek
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/db/encryption_utils.py` around lines 13 - 33, The cache logic
in get_encryption_key incorrectly treats None as "not cached", causing repeated
CONFIG.security.app_encryption_key reads and the wrong return type; change the
function to use a sentinel (e.g. a unique object or a separate boolean) or make
_cached_dek an Optional and a separate flag so that None can be cached, update
the function signature to -> str | None (or Optional[str]) to reflect possible
None, and ensure subsequent calls return the cached sentinel/None without
re-reading CONFIG; reference symbols: _cached_dek, get_encryption_key, and
CONFIG.security.app_encryption_key.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/fides/api/db/encryption_utils.py`:
- Around line 13-33: The cache logic in get_encryption_key incorrectly treats
None as "not cached", causing repeated CONFIG.security.app_encryption_key reads
and the wrong return type; change the function to use a sentinel (e.g. a unique
object or a separate boolean) or make _cached_dek an Optional and a separate
flag so that None can be cached, update the function signature to -> str | None
(or Optional[str]) to reflect possible None, and ensure subsequent calls return
the cached sentinel/None without re-reading CONFIG; reference symbols:
_cached_dek, get_encryption_key, and CONFIG.security.app_encryption_key.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c87e6be-1e52-40f2-8aa2-c5cd26ed4671

📥 Commits

Reviewing files that changed from the base of the PR and between 007e68e and df7a648.

📒 Files selected for processing (1)
  • src/fides/api/db/encryption_utils.py

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fides/api/models/privacy_request/webhook.py`:
- Line 12: The JWE helper code in webhook.py is using the process-wide cached
get_encryption_key which can return a stale DEK; update the JWE helper functions
(the token issuance code in webhook.py) to read the current key at runtime
instead of the cached getter—either call CONFIG.security.app_encryption_key
directly or add a new non-caching accessor (e.g., get_current_encryption_key)
and use that in place of get_encryption_key in the token creation/verification
functions so key rotation and test patches to CONFIG take effect immediately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a4b7ada7-8fe5-4d83-8335-45b52fb8a4bf

📥 Commits

Reviewing files that changed from the base of the PR and between df7a648 and 8cadc03.

📒 Files selected for processing (1)
  • src/fides/api/models/privacy_request/webhook.py


from pydantic import BaseModel, ConfigDict

from fides.api.db.encryption_utils import get_encryption_key
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid the process-wide DEK cache in these JWE helpers.

src/fides/api/db/encryption_utils.py caches CONFIG.security.app_encryption_key for the life of the worker. These functions used to read CONFIG at token-issuance time, so this change introduces a stale-key path for runtime key overrides/rotation and tests that patch CONFIG: new callback/download tokens can keep being minted with the old key until the cache is reset or the process restarts. That semantic change is broader than the ORM StringEncryptedType use case this PR is targeting.

🔐 Minimal fix
-from fides.api.db.encryption_utils import get_encryption_key
@@
     return generate_jwe(
         json.dumps(jwe.model_dump(mode="json")),
-        get_encryption_key(),
+        CONFIG.security.app_encryption_key,
     )
@@
     return generate_jwe(
         json.dumps(jwe.model_dump(mode="json")),
-        get_encryption_key(),
+        CONFIG.security.app_encryption_key,
     )
@@
     return generate_jwe(
         json.dumps(jwe.model_dump(mode="json")),
-        get_encryption_key(),
+        CONFIG.security.app_encryption_key,
     )
@@
     return generate_jwe(
         json.dumps(jwe.model_dump(mode="json")),
-        get_encryption_key(),
+        CONFIG.security.app_encryption_key,
     )

Also applies to: 68-71, 83-86, 99-102, 126-129

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/privacy_request/webhook.py` at line 12, The JWE helper
code in webhook.py is using the process-wide cached get_encryption_key which can
return a stale DEK; update the JWE helper functions (the token issuance code in
webhook.py) to read the current key at runtime instead of the cached
getter—either call CONFIG.security.app_encryption_key directly or add a new
non-caching accessor (e.g., get_current_encryption_key) and use that in place of
get_encryption_key in the token creation/verification functions so key rotation
and test patches to CONFIG take effect immediately.

@erosselli erosselli added this pull request to the merge queue Mar 9, 2026
Merged via the queue into main with commit 43f7e5b Mar 9, 2026
57 of 58 checks passed
@erosselli erosselli deleted the erosselli/2857 branch March 9, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants