Skip to content

feat(databases): warn when a stored JWT is expired or expiring - #46036

Open
gabotorresruiz wants to merge 7 commits into
apache:masterfrom
gabotorresruiz:feat/encrypted-extra-expiry-warning
Open

gabotorresruiz wants to merge 7 commits into
apache:masterfrom
gabotorresruiz:feat/encrypted-extra-expiry-warning

Conversation

@gabotorresruiz

@gabotorresruiz gabotorresruiz commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

SUMMARY

An expired JWT in a connection's Secure extra fails every query until someone opens the connection and notices. This PR warns the editor the moment the database modal opens, before any tab, panel or reveal click, when a stored JWT is expired or expires within seven days.

Stacked on #46035 (itself stacked on #45997); the first six commits belong to those PRs and this diff shrinks to the last commit once they merge.

What changes:

  • Database.stored_secret_expirations() returns, for each stored sensitive field whose value is a JWT with exp, its expiry and whether it is past, using the same decoder as the hints (payload only, exp only, never raises). GET /api/v1/database/<pk>/connection, the write-gated payload the edit form loads, carries it as engine_information.encrypted_extra_expirations. The plain database GET, the list and available do not carry it, and tests pin that, so readers see nothing new and SQL Lab bootstrap does no extra work.
  • A StoredSecretExpiryAlert component renders once above the tabs of the modal in edit mode, so it is visible from both Basic and Advanced, using the same wrapper and alert styling as the modal's other alerts. One dismissible warning lists each affected secret by its label: The stored JWT Token expired on Nov 4, 2026. Paste a new token into Secure extra and save., The stored JWT Token expires in 5 days (Nov 10, 2026). or The stored JWT Token expires today (Nov 5, 2026). Days are counted on the calendar, so the number always agrees with the date beside it. Nothing renders in create mode, without the map, or when no secret is expired or within seven days.
  • No request is made for this: the modal already fetches the connection payload on open.

Compatibility: no config change. The warning does not depend on ENCRYPTED_EXTRA_HINT_CHARS, because it carries no secret material (an expiry date, not characters of the token) and it is the part that prevents the outage, so an installation with the reveal switched off still gets it. An older frontend ignores the new key.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Expired token, first open of the modal, nothing expanded:

Warning above the tabs: the stored JWT Token expired on Nov 4, 2026

Token expiring in 5 days:

Warning: the stored JWT Token expires in 5 days

Token 60 days out, no warning:

Edit modal without a warning for a token expiring in 60 days

Create mode, no warning:

Create database modal without a warning

TESTING INSTRUCTIONS

Automated:

  • tests/unit_tests/models/core_test.py: expirations for an expiring and an expired token with a PEM key alongside (only the two JWTs listed), six empty or malformed cases, only declared fields considered.
  • tests/unit_tests/databases/api_test.py: /connection carries the map; the plain GET, the list and available do not.
  • DatabaseModal/index.test.tsx: expired and 7-day tokens warn as soon as the modal renders in edit mode without opening any tab or panel, several secrets in one alert, no warning at 8 days, in create mode, or without the map; StoredSecretExpiryAlert.test.tsx covers the component and the path fallback for an unlabelled field.

Manual:

  1. Store a JWT whose exp is yesterday in a Druid connection's Secure extra, open Edit: the warning shows above the tabs with no hints request in the network tab; dismiss it; reopen: it is back.
  2. Store one expiring in 5 days: the days-left wording. In 60 days: no warning.
  3. Set ENCRYPTED_EXTRA_HINT_CHARS = 0: the warning still shows, the reveal field does not.

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

Add a helper that reports, for every declared sensitive path of a
database's encrypted_extra, the length of the stored string and its last
few characters, so a masked secret can be told apart from another one
(for example after a token rotation).

The number of characters comes from the new ENCRYPTED_EXTRA_HINT_CHARS
setting (0 disables, which is the default) and is exposed to the
frontend. At most 25% of a trimmed value is ever revealed: values
shorter than four times the configured length only report their length.
Non-string values are skipped, wildcard paths expand per match, and
output is sorted by path. BaseEngineSpec.encrypted_extra_hints builds on
encrypted_extra_sensitive_field_paths and takes labels from
encrypted_extra_sensitive_fields when it is a dict.

A value that is a single PEM block (a service account or key-pair
private key) always ends with the same "-----END ...-----" footer, so
for those the suffix and the 25% cap use the base64 body with line
breaks and newline escapes removed. The reported length stays the
length of the stored value.
Add unhappy-path tests: unicode and trailing-newline values, a stored value equal to the mask literal, non-object or malformed JSON, and a 1 MB secret next to thousands of other keys.
…ra fields

Add GET /api/v1/database/<pk>/encrypted_extra_hints/, which returns the
label, path, length and short suffix of each sensitive field stored in a
database's encrypted_extra, so an editor can tell which secret is stored
without it being revealed. The suffix length comes only from the
ENCRYPTED_EXTRA_HINT_CHARS config; 0 or less returns an empty list.

The route maps to can_write on Database, so only users who can edit a
connection can call it. Calls go to the event log with the database id
and never the hint values, and the response is dumped through the schema
so nothing beyond the four declared keys can leave the server. The
published OpenAPI spec gains the new path and its two schemas.
…formation

Add the JSONPath and label of sensitive encrypted_extra fields to the
engine information returned by the database endpoints. The connection
modal needs to know, before any click, which Secure extra fields are
stored secrets and what to call them, so it can render a field per
stored secret up front and let the reveal control only change the value.

The database GET, the list and the available endpoint carry the engine's
declared patterns. The connection endpoint, which the edit modal loads
and which requires write access to databases, replaces them with the
fields stored for that database: every string value at a sensitive path,
keyed by its concrete path. An engine on the catch-all "$.*" rule
therefore shows one entry per stored string key instead of the pattern,
and objects are left out. The label is the declared one when the path is
declared exactly, else the key name. A missing or malformed
encrypted_extra gives an empty map. Readers of a database see nothing
about what is stored.

BaseEngineSpec gains encrypted_extra_sensitive_field_labels(), which maps
each path to its declared label (or the last path segment) with keys
sorted. The OAuth2 client secret that every engine adds is labeled
"OAuth2 client secret" unless the engine declares its own label.
encrypted_extra_hints reads its labels from the same method, and a new
get_sensitive_field_paths helper shares its path resolution with the
hints, so both list the same fields. Database.stored_sensitive_fields()
builds the per-database map. The published OpenAPI spec documents the
new key.
@netlify

netlify Bot commented Nov 5, 2026

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit e9dbd02
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6aec659619ae4b87a1b5ea46
😎 Deploy Preview https://deploy-preview-46036--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.

@github-actions github-actions Bot added i18n Namespace | Anything related to localization i18n:spanish Translation related to Spanish language i18n:italian Translation related to Italian language i18n:french Translation related to French language i18n:chinese Translation related to Chinese language i18n:japanese Translation related to Japanese language i18n:russian Translation related to Russian language i18n:korean Translation related to Korean language api Related to the REST API doc Namespace | Anything related to documentation i18n:dutch i18n:slovak i18n:ukrainian i18n:portuguese i18n:brazilian i18n:traditional-chinese i18n:persian i18n:czech i18n:latvian labels Nov 5, 2026
@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.62069% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.01%. Comparing base (2a8be96) to head (9e8be08).
⚠️ Report is 25 commits behind head on master.

Files with missing lines Patch % Lines
superset/db_engine_specs/base.py 92.00% 2 Missing ⚠️
...atabases/DatabaseModal/StoredSecretExpiryAlert.tsx 96.00% 1 Missing ⚠️
superset/databases/api.py 93.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #46036      +/-   ##
==========================================
+ Coverage   84.96%   85.01%   +0.04%     
==========================================
  Files        3078     3082       +4     
  Lines      208652   209376     +724     
  Branches    49167    49286     +119     
==========================================
+ Hits       177288   178004     +716     
+ Misses      27992    27989       -3     
- Partials     3372     3383      +11     
Flag Coverage Δ
hive 34.20% <37.83%> (+0.02%) ⬆️
javascript 81.30% <99.29%> (+0.02%) ⬆️
mysql 52.04% <72.29%> (-0.06%) ⬇️
postgres 52.06% <72.29%> (-0.06%) ⬇️
presto 35.85% <37.83%> (-0.03%) ⬇️
python 87.97% <97.97%> (+0.05%) ⬆️
sqlite 51.79% <63.51%> (-0.07%) ⬇️
unit 82.92% <96.62%> (+0.10%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 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.

@gabotorresruiz
gabotorresruiz marked this pull request as ready for review November 5, 2026 12:30
Comment on lines +45 to +47
const left = Date.parse(expiresAt) - Date.now();
if (Number.isNaN(left) || left > EXPIRY_WARNING_DAYS * DAY_MS) {
return undefined;

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: When the browser clock lags the server by over seven days, this filters out a server-unexpired token that expires soon, hiding its warning.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Logic error

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

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

**Path:** superset-frontend/src/features/databases/DatabaseModal/StoredSecretExpiryAlert.tsx
**Line:** 45:47
**Comment:**
	*Logic Error: When the browser clock lags the server by over seven days, this filters out a server-unexpired token that expires soon, hiding its warning.

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/utils/json.py Outdated
Comment on lines +413 to +414
if isinstance(exp, bool) or not isinstance(exp, int):
return None, None

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: Valid JWT NumericDate claims may contain fractional seconds, but this rejects every float, so those tokens never get an expiry or near-expiry warning.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Type error

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

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

**Path:** superset/utils/json.py
**Line:** 413:414
**Comment:**
	*Type Error: Valid JWT NumericDate claims may contain fractional seconds, but this rejects every float, so those tokens never get an expiry or near-expiry warning.

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

Choose a reason for hiding this comment

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

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of 3c26786.

The exp claim now accepts both integers and floats, rejects non-finite floats, and passes fractional timestamps to datetime.fromtimestamp, allowing fractional-second JWT expirations.

If that's not right, unresolve this thread and CodeAnt will leave it open.

@gabotorresruiz
gabotorresruiz force-pushed the feat/encrypted-extra-expiry-warning branch from e9dbd02 to 9e8be08 Compare November 5, 2026 15:31
Database editors cannot tell which secret is saved in Secure extra,
because the modal only shows the masked value. When a token is rotated
or pasted into the wrong connection, the only way to check was to
replace the secret blindly.

Render one read-only field per stored secret under Secure extra. The
fields are known before any request: the modal matches the masked
values of Secure extra against the sensitive fields listed in the
engine information of the connection payload, so each field shows its
final label and its storage path from the start. A concrete path keeps
its own label when a wildcard also matches it.

Like a password field, each field has its own eye, and it changes only
that field's value. The first eye clicked sends one request to the
encrypted_extra_hints endpoint and shows a loading state; an eye
clicked while that request is pending waits on it and loads too. The
answer is kept, so later eyes reveal their fields without a second
request. A revealed field shows masked bullets
followed by the last characters of its secret, and its eye hides it
again without touching the other fields. A failed request warns under
the field whose eye sent it and can be retried from any eye. The
label, the helper text and the input itself stay the same across a
reveal. A secret too short to reveal says so, with a tooltip giving
the minimum length, which is four times ENCRYPTED_EXTRA_HINT_CHARS.

Nothing is fetched until an eye is clicked, so opening the modal never
exposes even a partial secret. The fields render only when editing a
saved database whose Secure extra still holds a masked value at a
declared path and the ENCRYPTED_EXTRA_HINT_CHARS config is above zero.
The hints live in local state: a hidden field drops its ending from
the page, and closing the modal discards them.
Revealing the ending of a stored secret tells an editor which token is
stored. It does not tell them whether that token still works, and an
expired JWT fails only when a query runs. The reveal now also says when a
stored JWT expires, read from its exp claim.

Backend: `jwt_expiry` in `superset.utils.json` decodes the payload segment
of a three-segment base64url value whose header segment decodes to a JSON
object, and reads only `exp`: any finite number in range, as RFC 7519
allows a non-integer one. The signature is not verified (the server has no
key; the claim is informational) and no other claim is read, returned or
logged. Anything else, a PEM key or a plain password for example, gives
nulls. It never raises. Each hint from `encrypted_extra_hints` gains
`expires_at` (UTC) and `expired`, computed against the server clock, and
both are null for values that are not such a JWT. The response schema, the
OpenAPI spec and the engine spec README describe the two keys.

Frontend: in the revealed field's slot, `Expires <date>` in secondary
text or `Expired <date>` in the danger colour, with the date in the app's
locale-aware short format and a tooltip saying it is read from the exp
claim. A field without an expiry shows nothing there, and the too-short
and not-available notes keep the slot when they apply. The three new
strings are added to messages.pot; the weekly translation sync workflow
propagates the template into the catalogs.
A database whose stored JWT had expired kept failing every query until
someone found the cause. The edit modal can say so as soon as it opens,
before any click and without a new request: the connection payload it
already loads now carries when each stored token expires.

- Database.stored_secret_expirations() maps each stored sensitive field
  holding a JWT with an exp claim to its expiry and whether it has
  passed, read from the unverified exp claim. Other secrets, such as
  PEM keys and passwords, are left out.
- GET /api/v1/database/<pk>/connection sets it as
  engine_information.encrypted_extra_expirations, next to the stored
  sensitive fields. The plain GET, the list and available do not carry
  it, so readers learn nothing new and pay nothing.
- EngineInformationSchema documents the map; the OpenAPI spec gains the
  EncryptedExtraExpiration component.
- StoredSecretExpiryAlert shows one dismissible warning at the top of
  the edit modal, above the Basic and Advanced tabs, listing each
  stored secret that has expired or expires within 7 calendar days,
  by its label: "The stored JWT Token expired on <date>. Paste a new
  token into Secure extra and save.", "The stored JWT Token expires
  in 5 days (<date>)." or, on its last day, "The stored JWT Token
  expires today (<date>)." Days are counted between calendar dates in
  the browser's time zone, the same dates the line shows. Nothing
  shows in create mode or when no entry qualifies. Whether a token has
  expired is decided on the server.
- The warning does not depend on ENCRYPTED_EXTRA_HINT_CHARS: it carries
  no secret material, only when a token expires, and it is the part
  that prevents an outage, so an install with the reveal off still
  gets it.
- The three new strings are in messages.pot; the weekly translation
  sync workflow propagates the template into the catalogs.
@gabotorresruiz
gabotorresruiz force-pushed the feat/encrypted-extra-expiry-warning branch from 9e8be08 to 3c26786 Compare November 5, 2026 17:56
@github-actions github-actions Bot removed i18n:spanish Translation related to Spanish language i18n:italian Translation related to Italian language i18n:french Translation related to French language i18n:chinese Translation related to Chinese language i18n:japanese Translation related to Japanese language i18n:russian Translation related to Russian language i18n:korean Translation related to Korean language i18n:dutch i18n:slovak i18n:ukrainian i18n:portuguese i18n:brazilian i18n:traditional-chinese i18n:persian i18n:czech i18n:latvian labels Nov 5, 2026

This branch has not been deployed

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

Labels

api Related to the REST API doc Namespace | Anything related to documentation i18n Namespace | Anything related to localization size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant