Repository navigation
feat(databases): show when a stored JWT expires on reveal - #46035
gabotorresruiz wants to merge 6 commits into
Conversation
✅ Deploy Preview for superset-docs-preview ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
f892c03 to
aa7215f
Compare
| return loads(base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)).decode()) | ||
|
|
||
|
|
||
| def jwt_expiry(value: str) -> tuple[datetime | None, bool | None]: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@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); |
There was a problem hiding this comment.
expired is server clock and soon is client clock, so with skew past exp this renders plain "Expires …" with no warning.
There was a problem hiding this comment.
@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.
9c941cb to
ea5f887
Compare
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.
ea5f887 to
cf47338
Compare
…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.
cf47338 to
ed86018
Compare
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
expclaim, 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:
jwt_expiryhelper decodes a value shaped like a JWT (three base64url segments) with PyJWT's unverified decode, signature and claim checks off, and reads onlyexp, 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.GET /api/v1/database/<pk>/encrypted_extra_hints/gainsexpires_atandexpired, both nullable; the response schema and the OpenAPI spec bound them.Expires Jan 4, 2027in secondary text, with a warning icon when the date is within seven calendar days or already behind the browser's clock, orExpired Nov 4, 2026in the danger colour with an error icon, in the slot next to the eye, with a tooltip saying it is read from the token'sexpclaim. 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 inDatabaseModal/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 field for a token expiring within seven days, the warning icon in front of the date:
Revealed field for an expired token:
Tooltip on the expiry text:
TESTING INSTRUCTIONS
Automated:
tests/unit_tests/utils/json_tests.py: 50 helper cases (future, past, exact second, missingexp, 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-rangeexp, 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:expis 60 days ahead in a Druid connection's Secure extra, open Advanced > Security, click the eye: the ending plusExpires <date>.expis yesterday:Expired <date>in the danger colour.ADDITIONAL INFORMATION