Skip to content

fix(re-encrypt): handle non-id PKs and make command idempotent#40079

Merged
villebro merged 3 commits into
apache:masterfrom
villebro:villebro/secret-rotate
May 13, 2026
Merged

fix(re-encrypt): handle non-id PKs and make command idempotent#40079
villebro merged 3 commits into
apache:masterfrom
villebro:villebro/secret-rotate

Conversation

@villebro
Copy link
Copy Markdown
Member

@villebro villebro commented May 12, 2026

SUMMARY

Fixes several issues in superset re-encrypt-secrets that surfaced with the addition of the semantic_layers table (PK = uuid, with an encrypted configuration column):

  • Works on tables without an integer id PK. SecretsMigrator previously hardcoded SELECT id, ... FROM <table> and WHERE id = :id. It now derives the actual primary key column(s) from the Table metadata, so tables like semantic_layers (whose PK is uuid) can be rotated without error.
  • Idempotent re-runs. Re-encryption now checks whether the current key can already decrypt each value and skips if so, regardless of what previous_secret_key is supplied. Previously the check order was reversed, so re-running with a previous key that still happened to decrypt (e.g. after a successful rotation, or accidentally passing the current key as "previous") would re-encrypt every value on every run.
  • Per-column outcome summary. run() logs and returns a ReEncryptStats(re_encrypted, skipped, null, failed) count at the end of the flow. NULL values are counted separately from "already current" skips so operators can tell them apart. On failure, the transaction rolls back and the count of failed values is surfaced.
  • Cleaner CLI exit codes for deterministic DevOps flows:
    • Missing PREVIOUS_SECRET_KEY → exit 0 with a yellow "nothing to re-encrypt" notice (was exit 1). This lets scheduled rotation scripts run unconditionally after a rotation is complete without starting to fail.
    • Any failed field → exit 1 with a formatted "Re-encryption failed: ..." message (was a partial catch on ValueError only, letting generic exceptions leak as uncaught tracebacks).
    • Successful run → prints the stats summary in green.

BEFORE

2026-05-12 15:55:27,891:INFO:superset.utils.encrypt:Collecting info for re encryption

2026-05-12 15:55:27,891:INFO:superset.utils.encrypt:Processing table: dbs
Traceback (most recent call last):
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1910, in _execute_context
    self.dialect.do_execute(
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/default.py", line 736, in do_execute
    cursor.execute(statement, parameters)
sqlite3.OperationalError: no such column: id

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/ville/projects/superset/venv/bin/superset", line 6, in <module>
    sys.exit(superset())
             ^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/core.py", line 1442, in __call__
    return self.main(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/core.py", line 1363, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/core.py", line 1830, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/core.py", line 1226, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/core.py", line 794, in invoke
    return callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/decorators.py", line 34, in new_func
    return f(get_current_context(), *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/flask/cli.py", line 358, in decorator
    return __ctx.invoke(f, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/click/core.py", line 794, in invoke
    return callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/superset/cli/update.py", line 122, in re_encrypt_secrets
    secrets_migrator.run()
  File "/Users/ville/projects/superset/superset/utils/encrypt.py", line 218, in run
    rows = self._select_columns_from_table(conn, column_names, table_name)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/superset/utils/encrypt.py", line 156, in _select_columns_from_table
    return conn.execute(f"SELECT id, {','.join(column_names)} FROM {table_name}")  # noqa: S608
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1370, in execute
    return self._exec_driver_sql(
           ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1674, in _exec_driver_sql
    ret = self._execute_context(
          ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1953, in _execute_context
    self._handle_dbapi_exception(
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 2134, in _handle_dbapi_exception
    util.raise_(
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/util/compat.py", line 211, in raise_
    raise exception
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1910, in _execute_context
    self.dialect.do_execute(
  File "/Users/ville/projects/superset/venv/lib/python3.11/site-packages/sqlalchemy/engine/default.py", line 736, in do_execute
    cursor.execute(statement, parameters)
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such column: id
[SQL: SELECT id, configuration FROM semantic_layers]
(Background on this error at: https://sqlalche.me/e/14/e3q8)

AFTER

Successful rotation:

2026-05-12 16:26:17,793:INFO:superset.utils.encrypt:Collecting info for re encryption
2026-05-12 16:26:17,794:INFO:superset.utils.encrypt:Re-encryption summary: 2 re-encrypted, 0 skipped, 4 null, 0 failed
2026-05-12 16:26:17,794:INFO:superset.utils.encrypt:All tables processed
Re-encryption complete: 2 re-encrypted, 0 skipped, 4 null, 0 failed.

No-op (SECRET_KEY already decrypts values successfully):

2026-05-12 16:27:59,631:INFO:superset.utils.encrypt:Collecting info for re encryption
2026-05-12 16:27:59,634:INFO:superset.utils.encrypt:Re-encryption summary: 0 re-encrypted, 2 skipped, 4 null, 0 failed
2026-05-12 16:27:59,634:INFO:superset.utils.encrypt:All tables processed
Re-encryption complete: 0 re-encrypted, 2 skipped, 4 null, 0 failed.

Invalid secrets (neither SECRET_KEY nor PREVIOUS_SECRET_KEY decrypts current values):

2026-05-12 16:29:35,246:INFO:superset.utils.encrypt:Collecting info for re encryption
2026-05-12 16:29:35,247:ERROR:superset.utils.encrypt:Column [dbs.password] cannot be decrypted under the previous or current secret key (ValueError: Invalid decryption key)
2026-05-12 16:29:35,247:ERROR:superset.utils.encrypt:Column [dbs.encrypted_extra] cannot be decrypted under the previous or current secret key (ValueError: Invalid decryption key)
2026-05-12 16:29:35,248:INFO:superset.utils.encrypt:Re-encryption summary: 0 re-encrypted, 0 skipped, 4 null, 2 failed
Re-encryption failed: Re-encryption failed for 2 value(s); transaction rolled back

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

@dosubot dosubot Bot added the change:backend Requires changing the backend label May 12, 2026
@villebro villebro requested review from Vitor-Avila and dpgaspar May 12, 2026 23:32
@netlify
Copy link
Copy Markdown

netlify Bot commented May 12, 2026

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit bee410c
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a03b868e0ba1a0007f3798f
😎 Deploy Preview https://deploy-preview-40079--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 12, 2026

Codecov Report

❌ Patch coverage is 72.22222% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.12%. Comparing base (a77fec6) to head (b0e0a48).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
superset/utils/encrypt.py 70.21% 14 Missing ⚠️
superset/cli/update.py 85.71% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #40079      +/-   ##
==========================================
+ Coverage   64.08%   64.12%   +0.04%     
==========================================
  Files        2590     2590              
  Lines      137982   138011      +29     
  Branches    32008    32012       +4     
==========================================
+ Hits        88419    88499      +80     
+ Misses      48045    47993      -52     
- Partials     1518     1519       +1     
Flag Coverage Δ
hive 39.40% <18.51%> (+0.04%) ⬆️
mysql 59.10% <72.22%> (+0.10%) ⬆️
postgres 59.18% <72.22%> (+0.10%) ⬆️
presto 41.09% <18.51%> (+0.04%) ⬆️
python 60.61% <72.22%> (+0.10%) ⬆️
sqlite 58.82% <72.22%> (+0.10%) ⬆️
unit 100.00% <ø> (ø)

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

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread superset/cli/update.py
Comment on lines 117 to +122
if previous_secret_key is None:
click.secho("A previous secret key must be provided", err=True)
sys.exit(1)
secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)
try:
secrets_migrator.run()
except ValueError as exc:
click.secho(
f"An error occurred, "
f"probably an invalid previous secret key was provided. Error:[{exc}]",
err=True,
"No previous secret key provided; nothing to re-encrypt.",
fg="yellow",
)
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: The missing-key guard only checks for None, so an empty PREVIOUS_SECRET_KEY (for example an empty env var) is treated as a valid key and the migrator runs with "", causing avoidable decryption failures and exit 1. Treat blank/falsy values as missing in this branch so the command stays idempotent and no-op when no usable previous key is provided. [incorrect condition logic]

Severity Level: Major ⚠️
- ⚠️ CLI `re_encrypt_secrets` fails when PREVIOUS_SECRET_KEY is empty.
- ⚠️ Rotation scripts misbehave if env var left blank.
Steps of Reproduction ✅
1. Configure Superset with `PREVIOUS_SECRET_KEY` set to an empty string (e.g., via
environment) so `current_app.config["PREVIOUS_SECRET_KEY"] == ""` when
`re_encrypt_secrets()` in `superset/cli/update.py:113-133` is invoked without the
`--previous_secret_key` option.

2. In `re_encrypt_secrets`, line 114 assigns `previous_secret_key = previous_secret_key or
current_app.config.get("PREVIOUS_SECRET_KEY")`; with no CLI argument and an empty config
value this expression yields `""`, not `None`, so the guard `if previous_secret_key is
None:` at line 117 is bypassed and the early-return path is not taken.

3. The function constructs `SecretsMigrator(previous_secret_key=previous_secret_key)` at
line 123, passing the empty string into `SecretsMigrator.__init__`
(`superset/utils/encrypt.py:103-110`), and then calls `stats = secrets_migrator.run()`
within the transaction.

4. Inside `SecretsMigrator.run()` and `_re_encrypt_row()`
(`superset/utils/encrypt.py:180-259`), existing ciphertexts are unreadable under both the
current key and the empty previous key, so `stats.failed` is incremented and `run()`
raises at lines 59-63, causing `re_encrypt_secrets` to hit the `except` at lines 126-128
and exit with status 1 instead of the intended "nothing to re-encrypt" no-op for
missing/blank previous keys.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/cli/update.py
**Line:** 117:122
**Comment:**
	*Incorrect Condition Logic: The missing-key guard only checks for `None`, so an empty `PREVIOUS_SECRET_KEY` (for example an empty env var) is treated as a valid key and the migrator runs with `""`, causing avoidable decryption failures and exit 1. Treat blank/falsy values as missing in this branch so the command stays idempotent and no-op when no usable previous key is provided.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/cli/update.py
Comment on lines +123 to +127
secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)
try:
stats = secrets_migrator.run()
except Exception as exc: # pylint: disable=broad-except
click.secho(f"Re-encryption failed: {exc}", err=True)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: Exception handling starts after SecretsMigrator construction, so any failure during initialization (for example DB/engine access issues) bypasses the new failure path and leaks as an uncaught exception instead of the intended deterministic CLI error/exit behavior. Include migrator creation inside the same try block. [logic error]

Severity Level: Major ⚠️
- ⚠️ Initialization errors surface as uncaught tracebacks in CLI.
- ⚠️ DevOps scripts cannot rely on uniform failure message.
Steps of Reproduction ✅
1. Misconfigure the database/engine so that accessing
`superset.db.engine.url.get_dialect()` in `SecretsMigrator.__init__`
(`superset/utils/encrypt.py:103-110`) raises an exception when the CLI command is run.

2. Invoke the `re_encrypt_secrets` Click command defined in
`superset/cli/update.py:113-133` (e.g., via `superset re-encrypt-secrets`), which
evaluates `secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)` at
line 123.

3. The misconfiguration causes `SecretsMigrator.__init__` to raise before returning, but
this happens prior to the `try:` block that starts at line 124, so the broad `except
Exception as exc` at line 126 does not execute.

4. The initialization exception propagates out of `re_encrypt_secrets` as an uncaught
error, emitting a raw traceback instead of the intended deterministic "Re-encryption
failed: ..." message and controlled exit status used for failures arising inside
`secrets_migrator.run()`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/cli/update.py
**Line:** 123:127
**Comment:**
	*Logic Error: Exception handling starts after `SecretsMigrator` construction, so any failure during initialization (for example DB/engine access issues) bypasses the new failure path and leaks as an uncaught exception instead of the intended deterministic CLI error/exit behavior. Include migrator creation inside the same `try` block.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor

@bito-code-review bito-code-review Bot left a comment

Choose a reason for hiding this comment

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

Code Review Agent Run #591658

Actionable Suggestions - 3
  • tests/integration_tests/utils/encrypt_tests.py - 1
  • superset/utils/encrypt.py - 2
Review Details
  • Files reviewed - 4 · Commit Range: bee410c..bee410c
    • superset/cli/update.py
    • superset/utils/encrypt.py
    • tests/integration_tests/cli_tests.py
    • tests/integration_tests/utils/encrypt_tests.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment on lines +205 to +242
def test_re_encrypt_row_uses_pk_columns(self):
"""
Verify SecretsMigrator builds UPDATE statements targeting the table's
actual primary key columns rather than a hardcoded `id` column.
Regression guard for tables like `semantic_layers` whose PK is `uuid`.
"""
from unittest.mock import MagicMock

from sqlalchemy.engine import make_url

dialect = make_url("sqlite://").get_dialect()
migrator = SecretsMigrator(self.app.config["SECRET_KEY"])
migrator._dialect = dialect # noqa: SLF001

field = encrypted_field_factory.create(String(1024))
ciphertext = field.process_bind_param("hunter2", dialect)

conn = MagicMock()
row = {"uuid": b"\x00" * 16, "configuration": ciphertext}
stats = ReEncryptStats()

migrator._re_encrypt_row( # noqa: SLF001
conn,
row,
"semantic_layers",
{"configuration": field},
["uuid"],
stats,
)

assert conn.execute.call_count == 1
stmt = str(conn.execute.call_args.args[0])
assert "WHERE uuid = :_pk_uuid" in stmt
assert "id" not in stmt.split("WHERE", 1)[1]
kwargs = conn.execute.call_args.kwargs
assert kwargs["_pk_uuid"] == row["uuid"]
assert "configuration" in kwargs
assert stats == ReEncryptStats(re_encrypted=1, skipped=0, failed=0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Incorrect test logic for re-encryption

The test sets previous_secret_key equal to the current SECRET_KEY and encrypts data with the current key, so the method correctly skips re-encryption since the current key can decrypt. However, the test asserts re-encryption occurred. To properly test UPDATE statement building with PK columns, use a distinct previous key and encrypt the data with it.

Code Review Run #591658


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Comment thread superset/utils/encrypt.py Outdated
return
except Exception:
raise Exception from ex # pylint: disable=broad-exception-raised
except Exception as prev_ex: # pylint: disable=broad-except
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blind exception catch too broad

Avoid catching bare Exception. Catch specific exception types like ValueError or InvalidToken to handle only expected errors.

Code suggestion
Check the AI-generated fix before applying
Suggested change
except Exception as prev_ex: # pylint: disable=broad-except
except (ValueError, Exception) as prev_ex: # pylint: disable=broad-except

Code Review Run #591658


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Comment thread superset/utils/encrypt.py
stats.failed,
)
if stats.failed:
raise Exception( # pylint: disable=broad-exception-raised
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Avoid raising vanilla Exception class

Create a custom exception class instead of raising a generic Exception. Define a specific exception type like ReEncryptionError for better error handling.

Code suggestion
Check the AI-generated fix before applying
  from flask import Flask
 +
 +
 +class ReEncryptionError(Exception):
 +    """Exception raised when re-encryption fails."""
 
 @@ -318,8 +322,8 @@
              if stats.failed:
 -                raise Exception(  # pylint: disable=broad-exception-raised
 -                    f"Re-encryption failed for {stats.failed} value(s); "
 -                    "transaction rolled back"
 -                )
 +                error_msg = f"Re-encryption failed for {stats.failed} value(s); transaction rolled back"
 +                raise ReEncryptionError(error_msg)

Code Review Run #591658


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Copy link
Copy Markdown
Contributor

@Vitor-Avila Vitor-Avila left a comment

Choose a reason for hiding this comment

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

Haven't manually tested, but overall LGTM! Thanks for all the improvements to this flow

@villebro villebro merged commit af4dc3a into apache:master May 13, 2026
65 checks passed
@bito-code-review
Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped – PR Already Merged

Bito scheduled an automatic review for this pull request, but the review was skipped because this PR was merged before the review could be run.
No action is needed if you didn't intend to review it. To get a review, you can type /review in a comment and save it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants