Skip to content

feat(databases): show when a stored JWT expires on reveal - #46035

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

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

Conversation

@gabotorresruiz

@gabotorresruiz gabotorresruiz commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

SUMMARY

Revealing the ending of a stored secret tells an editor which token is stored, not whether it still works. For JWT-shaped values this PR also shows when the token expires, read from the token's own exp claim, so the reveal answers both questions with no extra characters of the secret.

Stacked on #45997 (which is stacked on #45996); the first five commits are those PRs and this diff shrinks to the last commit once they merge.

What changes:

  • A jwt_expiry helper decodes a value shaped like a JWT (three base64url segments) with PyJWT's unverified decode, signature and claim checks off, and reads only exp, accepted as any finite number per RFC 7519's NumericDate within a sane range. It returns a UTC datetime and whether it is in the past, computed server-side. It never raises, verifies no signature (the server has no key, and the claim is informational), and reads or returns nothing else from the payload. Values that are not JWTs, including PEM keys and plain passwords, yield nulls; so does a token whose segments are not valid base64url.
  • Each hint from GET /api/v1/database/<pk>/encrypted_extra_hints/ gains expires_at and expired, both nullable; the response schema and the OpenAPI spec bound them.
  • In the modal, a revealed field whose value is a JWT shows Expires Jan 4, 2027 in secondary text, with a warning icon when the date is within seven calendar days or already behind the browser's clock, or Expired Nov 4, 2026 in the danger colour with an error icon, in the slot next to the eye, with a tooltip saying it is read from the token's exp claim. The icon carries the severity because the theme's warning yellow is not readable as text. The too-short and not-available texts keep precedence. Dates use the app's locale-aware short format. The seven-day window and the calendar-day count live in DatabaseModal/expiry.ts, so the follow-up warning alert uses the same rule and the two agree on severity, including when the server has not yet marked a token expired but the browser clock has passed it.

Compatibility: no config change; the two new keys are null for anything that is not a JWT, and an older frontend ignores them. The expiry is shown only after the same explicit reveal click as the ending.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Revealed field for a token expiring in 60 days:

Revealed JWT Token field with Expires Jan 4, 2027 next to the ending

Revealed field for a token expiring within seven days, the warning icon in front of the date:

Revealed JWT Token field with a warning icon and Expires Nov 10, 2026 in default text

Revealed field for an expired token:

Revealed JWT Token field with Expired Nov 4, 2026 in the danger colour

Tooltip on the expiry text:

Tooltip reading Read from the token's exp claim

TESTING INSTRUCTIONS

Automated:

  • tests/unit_tests/utils/json_tests.py: 50 helper cases (future, past, exact second, missing exp, PEM, missing padding, year 9999, large inputs are fast, non-JWT shapes including a non-base64url signature, bad padding, non-object header and payload, float, non-numeric and out-of-range exp, non-string values), plus the hint fields for a JWT and an expired JWT with whitespace.
  • tests/unit_tests/databases/api_test.py: the endpoint returns the two keys for future and expired tokens; existing hint tests updated for the two null keys.
  • SecureExtraHints.test.tsx: expiry in secondary text, the warning icon within seven days and for a date the browser clock passed, expired in the danger colour with the error icon, tooltip, hidden field drops it, no expiry or an unreadable date leaves the slot empty, too-short keeps precedence. expiry.test.ts: the calendar-day count and the window on the same day, 7 days, 8 days, the past and an invalid date.

Manual, with ENCRYPTED_EXTRA_HINT_CHARS = 5:

  1. Store a JWT whose exp is 60 days ahead in a Druid connection's Secure extra, open Advanced > Security, click the eye: the ending plus Expires <date>.
  2. Store one whose exp is yesterday: Expired <date> in the danger colour.
  3. Store a non-JWT secret: no expiry text.

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

@netlify

netlify Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit cf47338
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6af25d8be0318515f8f12514
😎 Deploy Preview https://deploy-preview-46035--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 86.74242% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.39%. Comparing base (a131fcb) to head (ed86018).

Files with missing lines Patch % Lines
superset/utils/json.py 60.00% 15 Missing and 7 partials ⚠️
superset/db_engine_specs/base.py 64.70% 4 Missing and 2 partials ⚠️
superset/databases/api.py 78.57% 2 Missing and 1 partial ⚠️
...uperset/commands/database/encrypted_extra_hints.py 89.47% 1 Missing and 1 partial ⚠️
superset/models/core.py 75.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #46035      +/-   ##
==========================================
+ Coverage   65.35%   65.39%   +0.03%     
==========================================
  Files        3083     3086       +3     
  Lines      211224   211486     +262     
  Branches    49803    49871      +68     
==========================================
+ Hits       138054   138296     +242     
- Misses      69992    70001       +9     
- Partials     3178     3189      +11     
Flag Coverage Δ
hive 34.06% <39.83%> (+<0.01%) ⬆️
javascript 81.62% <100.00%> (+0.04%) ⬆️
mysql 51.83% <71.54%> (+0.02%) ⬆️
postgres 51.86% <71.54%> (+0.01%) ⬆️
presto 35.70% <39.83%> (+<0.01%) ⬆️
python 52.15% <71.54%> (+0.01%) ⬆️
sqlite 51.53% <65.85%> (+0.01%) ⬆️

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
@github-actions github-actions Bot removed 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
@gabotorresruiz
gabotorresruiz force-pushed the feat/encrypted-extra-expiry branch 3 times, most recently from f892c03 to aa7215f Compare November 6, 2026 01:32
Comment thread superset/utils/json.py
return loads(base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)).decode())


def jwt_expiry(value: str) -> tuple[datetime | None, bool | None]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PyJWT is already pinned; would jwt.decode(value, options={"verify_signature": False}) in one except jwt.PyJWTError not cover this without the hand-rolled decoder?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@msyavuz yes, that is simpler. The helper now uses PyJWT's unverified decode with the signature, exp, nbf and iat checks off, and keeps only two things on our side: a check that the three segments are base64url (PyJWT silently skips characters outside the alphabet) and the finite, in-range check on exp. All the edge-case tests still pass; the only visible difference is that a token whose signature segment is not valid base64url is no longer treated as a JWT, which seems right.

}
const date = extendedDayjs(expiresAt).format('ll');
// The icon carries the severity; the text keeps a readable colour.
const soon = !expired && isExpiringSoon(expiresAt);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

expired is server clock and soon is client clock, so with skew past exp this renders plain "Expires …" with no warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@msyavuz good point, the two clocks can disagree. A token whose exp the browser clock has already passed now counts as inside the warning window, so it shows the warning icon even if the server has not flagged it as expired yet. The alert in the next PR uses the same rule.

@github-actions github-actions Bot added the requires:rebase Requires rebasing on top of current master label Nov 7, 2026
@gabotorresruiz
gabotorresruiz force-pushed the feat/encrypted-extra-expiry branch 2 times, most recently from 9c941cb to ea5f887 Compare November 9, 2026 18:39
@github-actions github-actions Bot removed the requires:rebase Requires rebasing on top of current master label Nov 9, 2026
Add a helper that reports, for every declared sensitive path of a
database's encrypted_extra, the last few characters of the stored
string, 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: a value
shorter than four times the configured length gets no suffix. The
length of the value is never reported, because the ending is withheld
below that threshold precisely so a short secret gives nothing away, and
a length would still identify it. 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, newline escapes and the trailing "=" padding removed, so the
revealed characters are real base64 characters.

The setting is documented in Configuring Superset and announced in
UPDATING.md.
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.
@gabotorresruiz
gabotorresruiz force-pushed the feat/encrypted-extra-expiry branch from ea5f887 to cf47338 Compare November 9, 2026 23:55
…ra fields

Add GET /api/v1/database/<pk>/encrypted_extra_hints/, which returns the
label, path 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 three declared keys can leave the server. The
length of a stored value is not returned: it could identify a short
secret whose ending is withheld. 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 secrets every engine adds get readable labels unless the
engine declares its own: "OAuth2 client secret" for the OAuth2 client
secret, and "Embedded guest password", "Embedded guest private key
(PEM)" and "Embedded guest private key passphrase" for the embedded
guest credential.
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.
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.

Each field is a read-only password input whose value is empty while
hidden, after a static run of bullets, and its eye is the password
input's own toggle (focusable, Enter and Space, labelled Show or
Hide), which changes only that field. The first eye clicked sends one
request to the encrypted_extra_hints endpoint, with a spinner on the
right of the field; 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 the last
characters of its secret after the bullets, which do not move, 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 and the helper text 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, as does a save that
keeps the modal open, so an ending never outlives the stored value.
While Secure extra is being edited and briefly does not parse as JSON,
the fields of its last valid JSON stay, revealed or not.

Configuring Superset now says that the edit modal shows one field per
masked secret, with an eye that reveals its ending on request.
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 a three-segment
base64url value with PyJWT's unverified decode (header and payload must be
JSON objects, and every segment must be canonical base64url) 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, `Expired <date>` in the danger
colour after an error icon, or `Expires <date>` in default text after a
warning icon when the token expires today or within 7 calendar days, or
the browser clock has already passed it without the server calling it
expired, and in secondary text beyond that (the icon carries the severity,
so the text stays readable), 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
a readable 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. Configuring Superset notes the expiry.
@gabotorresruiz
gabotorresruiz force-pushed the feat/encrypted-extra-expiry branch from cf47338 to ed86018 Compare November 10, 2026 00:35

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.

2 participants