Skip to content

Add rotate_db_encryption_key management command#1532

Open
amasolov wants to merge 7 commits intoansible:mainfrom
amasolov:feature/rotate-db-encryption-key
Open

Add rotate_db_encryption_key management command#1532
amasolov wants to merge 7 commits intoansible:mainfrom
amasolov:feature/rotate-db-encryption-key

Conversation

@amasolov
Copy link
Copy Markdown
Contributor

@amasolov amasolov commented Apr 20, 2026

Closes #1531

What is being changed?

  • encrypt_string and decrypt_string in core/utils/crypto/fields.py now accept an optional key_material keyword argument, allowing callers to specify the encryption key explicitly instead of relying solely on settings.SECRET_KEY.
  • A new management command rotate_db_encryption_key is added under core/management/commands/. It re-encrypts all EncryptedTextField columns with a new key inside a single database transaction.

Why is this change needed?

Operators need to rotate SECRET_KEY periodically for security compliance. Automation Controller has awx-manage regenerate_secret_key and Automation Hub has pulpcore-manager rotate-db-key, but EDA has no equivalent. After rotating the key, all encrypted data is permanently lost.

How does this change address the issue?

The command provides a safe, atomic re-encryption path modelled directly after awx-manage regenerate_secret_key:

Aspect AWX regenerate_secret_key EDA rotate_db_encryption_key
@transaction.atomic on handle() Yes Yes
Exceptions bubble up and roll back Yes Yes
--use-custom-key with env var TOWER_SECRET_KEY EDA_SECRET_KEY
Key generation base64.encodebytes(os.urandom(33)) Same
handle() returns new key Yes Yes

Two improvements over the AWX counterpart:

  1. Dynamic field discovery via EncryptedTextField introspection (no static model list to maintain when new encrypted fields are added).
  2. --dry-run mode to report affected rows without writing to the database.

Does this change introduce any new dependencies, blockers or breaking changes?

No new dependencies. The key_material parameter is optional and defaults to settings.SECRET_KEY, so all existing callers are unaffected.

How can it be tested?

  • Unit tests are included for both the key_material parameter (tests/unit/test_encryption.py) and the management command (tests/unit/commands/test_rotate_db_encryption_key.py).
  • ruff check and ruff format pass cleanly.
  • On a running instance: aap-eda-manage rotate_db_encryption_key --dry-run

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Add a management command to rotate database encryption keys and re-encrypt stored secrets.
    • Accept a custom key via env var or generate and print a new key when created.
    • Dry-run mode previews affected values without applying changes; operations run atomically.
    • Encryption utilities accept an optional explicit key material for encrypt/decrypt.
  • Tests

    • Added tests for validation, dry-run reporting, key generation reporting, and re-encryption flows.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 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

Adds a new Django management command rotate_db_encryption_key to discover all model EncryptedTextField columns, decrypt values with the current SECRET_KEY, and re-encrypt them with either an environment-provided or generated key inside a single transaction. Crypto helpers accept optional key material and new tests cover rotation and key handling.

Changes

Cohort / File(s) Summary
Management Command
src/aap_eda/core/management/commands/rotate_db_encryption_key.py
New command that discovers EncryptedTextField fields, scans rows in PK-window batches, filters stored values by $encrypted$ marker, decrypts with current settings.SECRET_KEY, re-encrypts with either EDA_SECRET_KEY (when --use-custom-key) or a generated base64 key, supports --dry-run, and runs inside transaction.atomic.
Crypto Utilities
src/aap_eda/core/utils/crypto/fields.py
Extended encrypt_string/decrypt_string to accept optional key_material: Optional[str] and derive keys from it (falling back to settings.SECRET_KEY) to enable explicit-key encrypt/decrypt and rewrap operations.
Tests — command & encryption
tests/unit/commands/test_rotate_db_encryption_key.py, tests/unit/test_encryption.py
Adds unit tests covering missing EDA_SECRET_KEY validation, same-key abort, --dry-run reporting, re-encryption with custom and auto-generated keys (including printed generated key), and explicit-key encrypt/decrypt and rewrap flows.

Sequence Diagram

sequenceDiagram
    participant User
    participant Command
    participant Apps as "Django Apps Registry"
    participant DB as "Database"
    participant Crypto as "Crypto Utils"
    participant Env as "Env / Settings"

    User->>Command: invoke rotate_db_encryption_key (--use-custom-key / --dry-run)
    Command->>Env: read current SECRET_KEY (old_key)
    alt use custom key
        Command->>Env: read EDA_SECRET_KEY
        Env-->>Command: new_key (or error)
    else generate new key
        Command->>Crypto: generate random base64 key
        Crypto-->>Command: new_key
    end

    Command->>Apps: discover models with EncryptedTextField
    Apps-->>Command: list of (model, field) pairs

    Command->>DB: begin transaction
    loop per field (batched reads)
        DB-->>Command: fetch batch (pk > last_pk, LIMIT ...)
        Command->>Command: filter values containing "$encrypted$"
        loop per matching row
            Command->>Crypto: decrypt(ciphertext, key=old_key)
            Crypto-->>Command: plaintext
            Command->>Crypto: encrypt(plaintext, key=new_key)
            Crypto-->>Command: new_ciphertext
            alt not dry-run
                Command->>DB: UPDATE row SET field=new_ciphertext WHERE pk=...
            end
        end
    end
    alt error occurs
        DB->>DB: rollback
        Command-->>User: report failure / raise CommandError
    else success
        DB->>DB: commit
        Command-->>User: print "X value(s) re-encrypted" and printed new key (if generated)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a new Django management command for database encryption key rotation.
Description check ✅ Passed The description comprehensively covers all required sections: what is being changed, why it is needed, how it addresses the issue, dependencies/breaking changes, and testing procedures.
Linked Issues check ✅ Passed All objectives from issue #1531 are fully met: dynamic field discovery, atomic transaction handling, custom key support, dry-run mode, and AWX pattern alignment.
Out of Scope Changes check ✅ Passed All changes directly support the linked issue #1531: new management command, extended encryption functions, and comprehensive test coverage. No out-of-scope modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 94.44% 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 unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@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: 2

🧹 Nitpick comments (1)
tests/unit/commands/test_rotate_db_encryption_key.py (1)

33-67: Add a non-dry-run test that verifies actual re-encryption.

These tests only assert command output. Please add coverage that creates a row with an EncryptedTextField, runs the command without dry_run, then verifies the stored ciphertext changed and decrypts with the new key. That would cover the critical path in rotate_db_encryption_key.py:122-154, not just reporting.

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

In `@tests/unit/commands/test_rotate_db_encryption_key.py` around lines 33 - 67,
Add a non-dry-run unit test that creates a model instance containing an
EncryptedTextField, saves it with an initial SECRET_KEY, captures the original
ciphertext, runs call_command("rotate_db_encryption_key",
use_custom_key=True/False as appropriate, dry_run=False) to perform real
rotation (and set new key via os.environ["EDA_SECRET_KEY"] or
settings.SECRET_KEY), reloads the instance and asserts the stored ciphertext
changed and that decrypting the field with the new key yields the original
plaintext; refer to the rotate_db_encryption_key command, call_command,
EncryptedTextField and the SECRET_KEY/EDA_SECRET_KEY setup so the test covers
the code path in rotate_db_encryption_key.py lines 122-154.
🤖 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/aap_eda/core/management/commands/rotate_db_encryption_key.py`:
- Around line 117-120: The command leaks the encryption key because handle()
returns self.new_key (which BaseCommand.execute() will write to stdout) and also
explicitly writes it when not use_custom_key; remove the sensitive return value
by stopping handle() from returning the key—ensure you only call
self.stdout.write(self.new_key) when not use_custom_key and change the final
line that currently returns self.new_key to return None (or just return) so
BaseCommand.execute() does not print the key; locate symbols:
BaseCommand.execute(), handle(), self.new_key, use_custom_key, and
self.stdout.write to make this change.
- Around line 131-153: The current loop in rotate_db_encryption_key that uses
connection.cursor().execute(...) followed by cur.fetchall() (inside the for
model, field in fields block) loads all rows into memory and uses manual
double-quote string interpolation for identifiers; change it to iterate in
batches (use cursor.fetchmany(batch_size) or a server-side iterator) to avoid
fetchall and large memory spikes, and build SQL identifiers using
connection.ops.quote_name(model._meta.db_table) / quote_name(field.column) /
quote_name(model._meta.pk.column) instead of raw f-strings; keep the same SELECT
and UPDATE logic but page results (or use PK range/ORDER BY PK with LIMIT) and
update rows per-batch (still honoring dry_run) so the migration works across
different DB backends and large tables without OOMs.

---

Nitpick comments:
In `@tests/unit/commands/test_rotate_db_encryption_key.py`:
- Around line 33-67: Add a non-dry-run unit test that creates a model instance
containing an EncryptedTextField, saves it with an initial SECRET_KEY, captures
the original ciphertext, runs call_command("rotate_db_encryption_key",
use_custom_key=True/False as appropriate, dry_run=False) to perform real
rotation (and set new key via os.environ["EDA_SECRET_KEY"] or
settings.SECRET_KEY), reloads the instance and asserts the stored ciphertext
changed and that decrypting the field with the new key yields the original
plaintext; refer to the rotate_db_encryption_key command, call_command,
EncryptedTextField and the SECRET_KEY/EDA_SECRET_KEY setup so the test covers
the code path in rotate_db_encryption_key.py lines 122-154.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ba7fb550-9cf5-4512-8ff9-1fd74fa53722

📥 Commits

Reviewing files that changed from the base of the PR and between 344ba2b and 3069848.

📒 Files selected for processing (4)
  • src/aap_eda/core/management/commands/rotate_db_encryption_key.py
  • src/aap_eda/core/utils/crypto/fields.py
  • tests/unit/commands/test_rotate_db_encryption_key.py
  • tests/unit/test_encryption.py

Comment thread src/aap_eda/core/management/commands/rotate_db_encryption_key.py Outdated
Comment thread src/aap_eda/core/management/commands/rotate_db_encryption_key.py Outdated
@amasolov amasolov force-pushed the feature/rotate-db-encryption-key branch 2 times, most recently from fb21333 to 7b57742 Compare April 20, 2026 04:15
Copy link
Copy Markdown

@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/aap_eda/core/management/commands/rotate_db_encryption_key.py (1)

115-123: ⚠️ Potential issue | 🟠 Major

New key is still printed twice to stdout when --use-custom-key is not set.

BaseCommand.execute() writes any truthy value returned by handle() to self.stdout. Line 121 writes self.new_key, and then line 122 returns it, so Django writes it a second time. Operators piping the output to capture the new key will see it duplicated (and potentially copied incorrectly). Either print it or return it — not both.

🔧 Suggested fix
-        if not use_custom_key:
-            self.stdout.write(self.new_key)
-            return self.new_key
-        return None
+        if not use_custom_key:
+            return self.new_key
+        return None

Note: the test in tests/unit/commands/test_rotate_db_encryption_key.py::test_auto_generated_key_printed currently asserts only len(lines) >= 2, which passes regardless of the duplicate. Consider tightening it to assert the key appears exactly once.

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

In `@src/aap_eda/core/management/commands/rotate_db_encryption_key.py` around
lines 115 - 123, The command currently both writes self.new_key to stdout and
returns it from handle(), causing BaseCommand.execute() to emit the key twice;
modify rotate_db_encryption_key.handle() so it either prints the new key OR
returns it but not both — e.g., if not use_custom_key, either keep the
self.stdout.write(self.new_key) and return None, or remove the write and return
self.new_key — and update the test test_auto_generated_key_printed to assert the
key appears exactly once instead of len(lines) >= 2; ensure references to
dry_run, use_custom_key, and self.new_key in handle() are adjusted accordingly.
🤖 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/aap_eda/core/management/commands/rotate_db_encryption_key.py`:
- Around line 115-123: The command currently both writes self.new_key to stdout
and returns it from handle(), causing BaseCommand.execute() to emit the key
twice; modify rotate_db_encryption_key.handle() so it either prints the new key
OR returns it but not both — e.g., if not use_custom_key, either keep the
self.stdout.write(self.new_key) and return None, or remove the write and return
self.new_key — and update the test test_auto_generated_key_printed to assert the
key appears exactly once instead of len(lines) >= 2; ensure references to
dry_run, use_custom_key, and self.new_key in handle() are adjusted accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 85a4d463-fd34-4966-9cb1-cc32c9890dd4

📥 Commits

Reviewing files that changed from the base of the PR and between 3069848 and 7b57742.

📒 Files selected for processing (4)
  • src/aap_eda/core/management/commands/rotate_db_encryption_key.py
  • src/aap_eda/core/utils/crypto/fields.py
  • tests/unit/commands/test_rotate_db_encryption_key.py
  • tests/unit/test_encryption.py

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 20, 2026

Codecov Report

❌ Patch coverage is 98.97959% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.91%. Comparing base (344ba2b) to head (3dc2e1b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...re/management/commands/rotate_db_encryption_key.py 98.85% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main    #1532      +/-   ##
==========================================
- Coverage   91.93%   91.91%   -0.03%     
==========================================
  Files         239      241       +2     
  Lines       10810    10942     +132     
==========================================
+ Hits         9938    10057     +119     
- Misses        872      885      +13     
Flag Coverage Δ
unit-int-tests-3.11 91.91% <98.97%> (-0.03%) ⬇️
unit-int-tests-3.12 91.91% <98.97%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/aap_eda/core/utils/crypto/fields.py 88.67% <100.00%> (+1.17%) ⬆️
...re/management/commands/rotate_db_encryption_key.py 98.85% <98.85%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@amasolov amasolov force-pushed the feature/rotate-db-encryption-key branch 2 times, most recently from 08ae384 to d193b0a Compare April 20, 2026 04:34
Add a Django management command to re-encrypt all EncryptedTextField
columns after rotating the SECRET_KEY, similar to the existing
awx-manage regenerate_secret_key in Automation Controller.

The command supports:
- Generating a new key (printed to stdout for deployment update)
- Using a pre-generated key via EDA_SECRET_KEY env (--use-custom-key)
- Dry-run mode (--dry-run) to report affected rows without writing
- Full transaction wrapping for atomic re-encryption
- Batched row fetching via fetchmany() to avoid OOM on large datasets
- Backend-portable identifier quoting via connection.ops.quote_name()

Encrypted columns are discovered dynamically via EncryptedTextField
introspection rather than a static model list, so newly added
encrypted fields are covered without updating this command.

To enable explicit key material without mutating settings.SECRET_KEY,
encrypt_string and decrypt_string now accept an optional key_material
keyword argument.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
@amasolov amasolov force-pushed the feature/rotate-db-encryption-key branch from d193b0a to 73916ad Compare April 20, 2026 04:35
@amasolov
Copy link
Copy Markdown
Contributor Author

@mkanoor @ttuffin may I ask you to review this one please?

@ttuffin
Copy link
Copy Markdown
Contributor

ttuffin commented Apr 22, 2026

@amasolov will take a look at it soon. In the meantime can you please attend to the two SonarCloud issues? https://sonarcloud.io/project/security_hotspots?id=ansible_eda-server&pullRequest=1532&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true

Move raw SQL construction into dedicated _build_select_sql() and
_build_update_sql() static methods so cursor.execute() receives a
plain variable rather than an f-string.  This satisfies SonarCloud's
S3649/S608 SQL injection hotspot detection without requiring manual
triage in the SonarCloud UI.

Also replace "return self.new_key" with an explicit
self.stdout.write(self.new_key) to prevent BaseCommand.execute()
from writing the generated key a second time to stdout.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
Copy link
Copy Markdown

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/unit/test_encryption.py (1)

14-16: 🛠️ Refactor suggestion | 🟠 Major

Add negative assertions to detect when key_material parameter is ignored.

The test passes regardless of whether the encrypt/decrypt helpers actually respect the key_material parameter. If both functions silently ignore it and always use settings.SECRET_KEY, this test would still pass because every operation would use the same key. Add assertions with mismatched keys to catch this regression:

Suggested test tightening
 import pytest
+from cryptography.fernet import InvalidToken
 
 from aap_eda.core.utils.crypto.fields import decrypt_string, encrypt_string
@@
     assert ciphertext.startswith("$encrypted$fernet-256$")
 
     assert decrypt_string(ciphertext, key_material=old_k) == value
+    with pytest.raises(InvalidToken):
+        decrypt_string(ciphertext)
+    with pytest.raises(InvalidToken):
+        decrypt_string(ciphertext, key_material=new_k)
 
     rewrapped = encrypt_string(
         decrypt_string(ciphertext, key_material=old_k),
         key_material=new_k,
     )
     assert decrypt_string(rewrapped, key_material=new_k) == value
+    with pytest.raises(InvalidToken):
+        decrypt_string(rewrapped, key_material=old_k)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_encryption.py` around lines 14 - 16, The current test for
encrypt_string/decrypt_string does not assert behavior when key_material
differs, so add negative assertions to ensure key_material is actually used:
call encrypt_string with one key_material and assert that decrypt_string fails
(raises or returns incorrect value) when called with a different key_material,
and also verify that using settings.SECRET_KEY vs a distinct key yields
different ciphertexts or decryption failures; update tests referencing
decrypt_string, encrypt_string and key_material to include these mismatched-key
assertions so the functions cannot silently ignore the key_material parameter.
🧹 Nitpick comments (1)
tests/unit/commands/test_rotate_db_encryption_key.py (1)

37-52: Seed a row so dry-run proves it does not write.

This test currently passes with zero encrypted rows and does not verify persistence is unchanged. Capture the ciphertext before/after dry-run and assert the count.

Suggested dry-run coverage
 def test_dry_run_reports_without_writing(settings):
     """--dry-run completes and reports without committing."""
     settings.SECRET_KEY = "test-secret-for-rotation-command"
+    Setting.objects.create(key="dry_run_rotation", value="dry-run-secret")
+    with connection.cursor() as cur:
+        cur.execute(
+            "SELECT value FROM core_setting WHERE key = %s",
+            ["dry_run_rotation"],
+        )
+        old_cipher = cur.fetchone()[0]
+
     out = io.StringIO()
     with patch.dict(
         os.environ,
         {"EDA_SECRET_KEY": "new-secret-key-for-rotation"},
@@
             stdout=out,
         )
-    assert "would be re-encrypted" in out.getvalue()
+    assert "1 value(s) would be re-encrypted." in out.getvalue()
+    with connection.cursor() as cur:
+        cur.execute(
+            "SELECT value FROM core_setting WHERE key = %s",
+            ["dry_run_rotation"],
+        )
+        new_cipher = cur.fetchone()[0]
+    assert new_cipher == old_cipher
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/commands/test_rotate_db_encryption_key.py` around lines 37 - 52,
In test_dry_run_reports_without_writing, seed a database row with an encrypted
column before invoking call_command("rotate_db_encryption_key", ...), capture
its ciphertext (e.g., read the model instance’s encrypted field or raw DB
column), run the command with dry_run=True, then re-read the ciphertext/count
and assert the ciphertext and row count are unchanged and no new ciphertext was
written; this proves dry-run does not persist changes.
🤖 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/aap_eda/core/management/commands/rotate_db_encryption_key.py`:
- Around line 100-110: Add a guard after computing self.old_key and self.new_key
in the command (where use_custom_key is processed) to detect when the two keys
are identical and abort with a clear error; specifically, in the same scope
where self.old_key and self.new_key are set (referenced by the symbols
self.old_key, self.new_key and use_custom_key), compare them and raise
CommandError if they match (with a message like "New encryption key is identical
to the current SECRET_KEY; rotation aborted") so the rotation does not silently
become a no-op.
- Around line 175-181: The current loop in rotate_db_encryption_key.py uses
connection.cursor() (client-side) which buffers the entire SELECT; change it to
use a server-side cursor or PK-window pagination so scans are bounded-memory:
either create a named cursor (e.g., connection.cursor(name='batch_cursor') and
then execute select_sql and fetchmany(_FETCH_BATCH_SIZE)) or rewrite the scan to
paginate by primary key range (looping by max PK fetched and re-running a SELECT
... WHERE pk > last_pk ORDER BY pk LIMIT _FETCH_BATCH_SIZE), keeping the
existing call to self._reencrypt_rows(rows, update_sql, dry_run) and preserving
dry_run behavior. Ensure you close the cursor after use and that variable names
select_sql, update_sql, _reencrypt_rows, _FETCH_BATCH_SIZE and dry_run are used
consistently.

---

Outside diff comments:
In `@tests/unit/test_encryption.py`:
- Around line 14-16: The current test for encrypt_string/decrypt_string does not
assert behavior when key_material differs, so add negative assertions to ensure
key_material is actually used: call encrypt_string with one key_material and
assert that decrypt_string fails (raises or returns incorrect value) when called
with a different key_material, and also verify that using settings.SECRET_KEY vs
a distinct key yields different ciphertexts or decryption failures; update tests
referencing decrypt_string, encrypt_string and key_material to include these
mismatched-key assertions so the functions cannot silently ignore the
key_material parameter.

---

Nitpick comments:
In `@tests/unit/commands/test_rotate_db_encryption_key.py`:
- Around line 37-52: In test_dry_run_reports_without_writing, seed a database
row with an encrypted column before invoking
call_command("rotate_db_encryption_key", ...), capture its ciphertext (e.g.,
read the model instance’s encrypted field or raw DB column), run the command
with dry_run=True, then re-read the ciphertext/count and assert the ciphertext
and row count are unchanged and no new ciphertext was written; this proves
dry-run does not persist changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d2125391-330b-4df3-9d84-c3fc009a9919

📥 Commits

Reviewing files that changed from the base of the PR and between 7b57742 and b68178a.

📒 Files selected for processing (4)
  • src/aap_eda/core/management/commands/rotate_db_encryption_key.py
  • src/aap_eda/core/utils/crypto/fields.py
  • tests/unit/commands/test_rotate_db_encryption_key.py
  • tests/unit/test_encryption.py

Comment thread src/aap_eda/core/management/commands/rotate_db_encryption_key.py
Comment thread src/aap_eda/core/management/commands/rotate_db_encryption_key.py Outdated
- Guard against no-op rotation when new key equals old key
- Switch from fetchmany to PK-window pagination so the database
  driver only buffers one page of rows at a time
- Strengthen dry-run test: seed a row and verify ciphertext is
  unchanged after --dry-run
- Add negative key assertions to test_encryption.py: decrypting
  with the wrong key must raise InvalidToken

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
Copy link
Copy Markdown

@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 (2)
tests/unit/commands/test_rotate_db_encryption_key.py (1)

82-96: Consider seeding a row for test_auto_generated_key_printed_once.

As written, total=0 because no Setting is created, so the test only verifies the "0 value(s)" branch. Seeding a row would also cover the common case where rotation actually occurred and still keep the "printed once" invariant.

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

In `@tests/unit/commands/test_rotate_db_encryption_key.py` around lines 82 - 96,
The test test_auto_generated_key_printed_once never creates any Setting row so
the command exercise only the "total=0" branch; before invoking
call_command("rotate_db_encryption_key", ...) create/seed a Setting record (via
the Setting model or test factory) with appropriate key/value so the rotation
logic runs and still assert the generated key line appears exactly once; ensure
you reference the Setting model used by the command and keep the rest of the
assertions the same.
src/aap_eda/core/management/commands/rotate_db_encryption_key.py (1)

183-197: last_pk = 0 is fragile but not currently broken—consider future-proofing against non-integer PKs.

The code assumes integer PKs, which works for all current EncryptedTextField-bearing models. However, since encrypted columns are discovered dynamically, a future refactoring adding a UUID or string PK to any of these models would silently fail or error (e.g., operator does not exist: uuid > integer on Postgres). To prevent this fragility, either seed the initial window with a type-safe sentinel (e.g., last_pk = None) or run the first page without the PK predicate. This is low-priority now but worth addressing to avoid subtle bugs from future schema changes.

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

In `@src/aap_eda/core/management/commands/rotate_db_encryption_key.py` around
lines 183 - 197, The loop in _reencrypt_column assumes integer PKs by seeding
last_pk = 0; change it to last_pk = None and modify how you call
_build_select_page_sql/_execute so the first page runs without a "pk > %s"
predicate: update _reencrypt_column to initialize last_pk = None, call
_build_select_page_sql(model, field, last_pk) (add an optional last_pk param),
and in _build_select_page_sql emit the WHERE clause only when last_pk is not
None; when executing the query pass [] for params on the initial run and
[last_pk] thereafter; leave _build_update_sql and _reencrypt_rows unchanged.
🤖 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/aap_eda/core/management/commands/rotate_db_encryption_key.py`:
- Around line 49-54: The iterator _iter_encrypted_text_fields currently iterates
model._meta.get_fields(), which includes inherited fields and can cause
duplicate or wrong-column processing; restrict iteration to fields defined on
the model itself by using model._meta.local_fields (or by filtering get_fields()
with field.model is model) and yield only fields that are instances of
EncryptedTextField, so the produced (model_class, field) pairs refer only to
columns that belong to that model.

---

Nitpick comments:
In `@src/aap_eda/core/management/commands/rotate_db_encryption_key.py`:
- Around line 183-197: The loop in _reencrypt_column assumes integer PKs by
seeding last_pk = 0; change it to last_pk = None and modify how you call
_build_select_page_sql/_execute so the first page runs without a "pk > %s"
predicate: update _reencrypt_column to initialize last_pk = None, call
_build_select_page_sql(model, field, last_pk) (add an optional last_pk param),
and in _build_select_page_sql emit the WHERE clause only when last_pk is not
None; when executing the query pass [] for params on the initial run and
[last_pk] thereafter; leave _build_update_sql and _reencrypt_rows unchanged.

In `@tests/unit/commands/test_rotate_db_encryption_key.py`:
- Around line 82-96: The test test_auto_generated_key_printed_once never creates
any Setting row so the command exercise only the "total=0" branch; before
invoking call_command("rotate_db_encryption_key", ...) create/seed a Setting
record (via the Setting model or test factory) with appropriate key/value so the
rotation logic runs and still assert the generated key line appears exactly
once; ensure you reference the Setting model used by the command and keep the
rest of the assertions the same.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 44b7370a-abe8-4d7c-976d-9f1ed68f6c01

📥 Commits

Reviewing files that changed from the base of the PR and between b68178a and 864bee5.

📒 Files selected for processing (3)
  • src/aap_eda/core/management/commands/rotate_db_encryption_key.py
  • tests/unit/commands/test_rotate_db_encryption_key.py
  • tests/unit/test_encryption.py

Comment thread src/aap_eda/core/management/commands/rotate_db_encryption_key.py
Filter _iter_encrypted_text_fields() to only yield fields where
field.model is the current model, preventing double re-encryption
when multi-table inheritance causes get_fields() to return the
same column for both parent and child models.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
The first page is now fetched without a PK bound predicate, and
subsequent pages use the actual PK value from the previous batch.
This removes the assumption that PKs are integers (last_pk = 0)
and works correctly with UUID or any other ordered PK type.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
Copy link
Copy Markdown

@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/aap_eda/core/management/commands/rotate_db_encryption_key.py`:
- Around line 129-135: The command currently prints self.new_key even during a
dry-run; change the logic so the generated key is only emitted when the command
will actually perform writes: update the condition that writes the key
(currently "if not use_custom_key: self.stdout.write(self.new_key)") to also
check that dry_run is False (e.g. "if not use_custom_key and not dry_run: ...")
inside the RotateDBEncryptionKey command/handle method so a generated key is
never shown during --dry-run.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5eef8484-643d-4cd2-bccc-cefae2baa167

📥 Commits

Reviewing files that changed from the base of the PR and between 864bee5 and 7deb67e.

📒 Files selected for processing (1)
  • src/aap_eda/core/management/commands/rotate_db_encryption_key.py

Comment thread src/aap_eda/core/management/commands/rotate_db_encryption_key.py
An operator could mistakenly persist a key printed during dry-run
and break decryption for the unchanged database. Only emit the
auto-generated key when writes actually occur.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
The previous run failed on test_advisory_lock.py::test_job_uniqueness
[monitor_project_tasks], which is a pre-existing integration test
unrelated to the encryption key rotation changes.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@ttuffin ttuffin left a comment

Choose a reason for hiding this comment

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

LGTM, thanks for the contribution

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.

Add management command to re-encrypt database fields after SECRET_KEY rotation

3 participants