diff --git a/UPDATING.md b/UPDATING.md index 7f3c61dce4b3..28b24ac01f5e 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,35 @@ assists people when migrating to a new version. ## Next +- `EMBEDDED_CREDENTIAL_FALLBACK` (new, off by default) changes behaviour for embedded + guests on database connections that authenticate users individually via OAuth2 with + **Impersonate logged in user** enabled. Ordinary service-account connections retain + their existing behavior, even with a deployment-wide OAuth2 client configured. With + the flag on, a guest whose request has no applicable embedded credential — none + configured on the connection, or one missing its username, password, or private key + required by the selected authentication method — is refused + with a `CONNECTION_ACCESS_DENIED_ERROR` instead of connecting with the credential in + the connection's own SQLAlchemy URI. Queries that do run under an embedded credential + get a dedicated cache partition distinct from every logged-in username, including + a separate partition for guests without credentials, so they no longer share a + DATA-cache entry (or, under `GLOBAL_ASYNC_QUERIES`, a deduplicated chart task) with a logged-in user's + per-user result; existing cached entries for such charts are effectively invalidated + for guests. A malformed or incomplete `embedded_credentials` is rejected by + `POST`/`PUT /api/v1/database/`, by `validate_parameters`, and by database and asset + import (HTTP 400, naming the missing fields) rather than stored and ignored while + both embedding flags are enabled. Validation + uses the engine's credential rules and edited credentials rather than stale stored + values, and rejects whitespace-only usernames. Disabling either flag preserves stored credentials without blocking unrelated edits. Restored async guest + queries use the same credential as synchronous queries. Connection-mutator hooks + receive the selected fallback username. Snowflake OAuth2 scope is + configured explicitly; a role scope selects that role, and omitting it uses the + user's default role. Deployments that leave the fallback flag off are unaffected, except + that, whatever the flag, dashboard, chart and dataset exports now carry any masked + OAuth2 client and embedded credential blocks, and overwriting a connection from a + dashboard bundle merges them into its Secure Extra. A destination still on an earlier + release replaces its whole Secure Extra with those blocks instead, so upgrade import + destinations first. + - Dashboard and chart `viewers` can no longer be added through the API or the MCP `manage_dashboard_roles` tool while the `ENABLE_VIEWERS` feature flag is off; such requests are rejected with a 422. Enforcement of existing viewers is unchanged (a diff --git a/docs/docs/using-superset/embedding.mdx b/docs/docs/using-superset/embedding.mdx index 0430d1a94fa5..493608874788 100644 --- a/docs/docs/using-superset/embedding.mdx +++ b/docs/docs/using-superset/embedding.mdx @@ -172,6 +172,113 @@ When enabled, the **Logout** menu item is removed from the user avatar dropdown Must be `True` to enable the embedded SDK and the guest token endpoint. Without this flag, `embedDashboard` will fail to load. +### `EMBEDDED_CREDENTIAL_FALLBACK` + +Allows a database connection that authenticates logged-in users per-user via OAuth2 to +also carry a single Snowflake username with either a password or a PEM key pair, used +**only** for embedded guest requests. + +This exists because the two authentication models do not meet. An embedded viewer signs +in with a guest token rather than a Superset account, so there is no per-user OAuth2 +token to resolve for them — and an external viewer may have no identity on the +analytical database at all. Without a fallback, embedded dashboards on a per-user OAuth2 +connection cannot run queries. + +```python +# superset_config.py +FEATURE_FLAGS = { + "EMBEDDED_SUPERSET": True, + "EMBEDDED_CREDENTIAL_FALLBACK": True, +} +``` + +With both flags on, engines that support it (currently Snowflake) show an **Embedded +guest credentials** section on the **Basic** tab of the connection dialog. The section +is editable only when the connection has an OAuth2 client and **Impersonate logged in +user** is on; otherwise it is disabled and states what is missing. + +#### Setting up the credential + +1. Enable `EMBEDDED_SUPERSET` and `EMBEDDED_CREDENTIAL_FALLBACK` (above). +2. Open the Snowflake connection for editing. On the **Basic** tab, expand **OAuth2 + client information** and enter the client ID, client secret, authorization and token + request URIs, and scope. Alternatively, configure a deployment-wide client for + Snowflake in `DATABASE_OAUTH2_CLIENTS`. +3. On the **Advanced** tab, under **Security**, turn on **Impersonate logged in user**. + On a new connection this option appears only after you connect, so connect first and + then edit the connection. +4. Back on the **Basic** tab, expand **Embedded guest credentials**, enter the Snowflake + username, choose **Password** or **Key pair**, and fill in the secret. +5. Save. The section header shows **Configured** once a credential is stored. + +Configure the Snowflake OAuth2 scopes explicitly to match your integration. For +example, `session:role:ANALYST refresh_token` selects the `ANALYST` role and requests a +refresh token. Omitting a role scope uses the user's default role; the dialog does not +prefill a scope or require a specific role scope. + +For key-pair authentication, enter the Snowflake username, its PEM private key, and an +optional passphrase; register the corresponding public key with that Snowflake user. The +credential is stored encrypted in the connection's *Secure Extra* under +`embedded_credentials`. Passwords, private keys, and passphrases are masked on read, +while the username stays visible so an administrator can see which database user +embedded queries run as. + +The credential must be complete: a username with no password or private key, or the +reverse, is rejected when both embedding flags are enabled. A whitespace-only username +is invalid. Validation uses the engine's credential rules and rejects malformed JSON +or non-object credential values. It checks the edited credential, so an incomplete +imported credential can be completed or cleared. + +To remove the credential, expand **Embedded guest credentials**, click **Remove embedded +guest credentials** (or clear every field), and save. This deletes the +`embedded_credentials` key from *Secure Extra*. Removal stays available while the section +is otherwise disabled. + +#### Requirements and limitations + +- Both `EMBEDDED_SUPERSET` and `EMBEDDED_CREDENTIAL_FALLBACK` must be enabled. While + either is off, a stored credential is preserved, unused, and does not block unrelated + edits. +- The connection needs an OAuth2 client — per-connection **OAuth2 client information** + or a deployment-wide `DATABASE_OAUTH2_CLIENTS` entry — and **Impersonate logged in + user** must be on. Superset refuses to save a credential otherwise, and it never + applies to a connection without OAuth2. +- Embedded viewers run with the embedded account's **default Snowflake role**. The + connection's configured role and authenticator are not used for guest connections. +- The embedded account needs `USAGE` on the connection's warehouse. +- Supported secrets are a password, a Snowflake programmatic access token entered as the + password, or a key pair (PEM private key with an optional passphrase). +- Alerts, reports, connection tests, and logged-in users never use the credential; they + keep using the connection's own credential or their per-user OAuth2 token. +- With `GLOBAL_ASYNC_QUERIES` enabled, guest queries use the same fallback as + synchronous guest queries, but logged-in users' async queries are not covered by it. + +Understand what this grants before enabling it: + +- **Every embedded viewer of that connection queries as that one database user.** Guest + tokens carry no database identity, so per-user attribution and any database-side + row-level security keyed to the user do not apply. Use a dedicated account with the + narrowest access the embedded dashboards need, and enforce per-viewer restrictions + with Superset's own [row-level security](#security-notes) rules in the guest token. +- **Logged-in users are unaffected.** They continue to authenticate individually via + OAuth2 and have no route to the stored credential. +- **The guest token's `username` claim is never used to connect.** Whoever mints the + token controls that claim, so it is deliberately kept out of the connection identity. + Connection-mutator hooks receive the selected fallback connection username. +- **An embedded viewer never silently falls back to the connection's base credential.** + With this flag on, a guest on a per-user OAuth2 connection with impersonation enabled + that has no applicable credential — + none configured, or one that is incomplete — is refused with an error rather than + connected as whichever account the connection URL carries. Deployments that leave the + flag off keep their existing behaviour. +- **Guest results are cached separately.** Queries run under the fallback credential get + their own cache partition, distinct from every logged-in username, so an embedded + viewer and a logged-in user never share a cached result (or, with + `GLOBAL_ASYNC_QUERIES`, a deduplicated query task) for the same chart. Missing or incomplete credentials also receive a separate cache key, so a + warm logged-in cache cannot bypass the missing-credential refusal. +- Turning the flag back off is a kill switch, not a deletion: the stored credential stops + being offered and stops being used, but is preserved. + --- ## URL Parameters @@ -214,6 +321,7 @@ One caveat when the host page lives on a different site than Superset: the defau - **Row-level security** — pass `rls` rules in the guest token request to restrict which rows are visible to the embedded user. Semantic views cannot enforce guest-token SQL clauses, so queries and cached-result reads are rejected when a global rule or a rule scoped to the semantic view applies. For hosts, a global guest rule deliberately refuses every semantic view, and a dataset-scoped rule can also refuse a semantic view even when the rule was intended for a SQL dataset. Read permissions do not override these restrictions. Semantic views remain accessible when no guest rule applies; SQL datasets continue to enforce their applicable rules. - **Allowed domains** — restrict which host origins can embed a dashboard by setting **Allowed Domains** per-dashboard in the _Embed_ settings modal. Superset checks the request's `Referer` header against this list before serving the embedded view; an empty list allows any origin, so configure this explicitly for production. - **Redacted errors** — API responses to a guest token report a generic `An error occurred while fetching the data.` instead of the underlying error, since engine errors quote catalog, schema, table and column names. Errors Superset raises itself — access denials, timeouts, payload validation — keep their message, and the full error is always available in the server logs. +- **OAuth2 connections and guests** — a guest cannot complete an OAuth2 authorization, so Superset does not start one for them, and an embedded viewer on an OAuth2 connection with no applicable credential is never sent into a login flow they cannot finish. With `EMBEDDED_CREDENTIAL_FALLBACK` off, the connection attempt fails with the database driver's authentication error. With it on, Superset refuses the query before connecting. Either way the viewer sees the generic `An error occurred while fetching the data.` message, and the reason is in the server logs. To serve embedded dashboards from such a connection, see [`EMBEDDED_CREDENTIAL_FALLBACK`](#embedded_credential_fallback). ## Guest-token request-header size diagnostics diff --git a/docs/sip/embedded-fallback-credential.md b/docs/sip/embedded-fallback-credential.md new file mode 100644 index 000000000000..9293c055f040 --- /dev/null +++ b/docs/sip/embedded-fallback-credential.md @@ -0,0 +1,280 @@ + + +# SIP: Embedded-only fallback credential on OAuth2 database connections + +## [DRAFT — proposal for discussion] + +This document is a draft proposal accompanying the code in this PR. It is intended to +seed the formal SIP discussion. The code is complete and tested, gated behind a new +feature flag that defaults to `False`, and changes nothing for any existing deployment +until that flag is turned on. + +## Motivation + +Per-user database OAuth2 and embedded dashboards do not currently compose. + +When a database connection authenticates users individually via OAuth2, Superset +resolves a per-user token for the requesting principal and uses it to connect. An +embedded viewer, however, signs in with a guest token rather than a Superset account. +There is no Superset user, so there is no per-user token to resolve — and an external +viewer (a customer, a partner, a member of the public) frequently has no identity on the +analytical database at all, so there is no token that *could* be minted for them. + +The result today is that embedded dashboards simply cannot run queries against a +per-user OAuth2 connection. Until the companion fix in +`fix(oauth2): don't start the OAuth2 dance for embedded guests`, the failure was also +unhelpful: Superset tried to start an authorization flow the guest cannot complete, +read `g.user.id` on a principal that has none, and returned an HTTP 500 instead of the +driver's error. + +This is not a hypothetical. It is the shape of every deployment that embeds dashboards +for external viewers while moving internal users onto per-user OAuth2 — a direction +several warehouses, Snowflake among them, actively push customers toward. + +The workarounds available today are all worse: + +- **Keep a second connection** with service-account credentials for embedded use. This + duplicates every connection-level setting and relies on nothing ever pointing an + internal dashboard at the service-account copy. +- **Drop per-user OAuth2** for any database that feeds an embedded dashboard, losing + per-user attribution for internal users to serve external ones. +- **Mint a Superset account per external viewer**, which defeats the purpose of guest + tokens. + +## Proposed change + +Let a single connection carry a username and either a password or a PEM private key used +**only** when the requesting principal is an embedded guest. Logged-in users continue to +authenticate per-user via OAuth2, with no route by which they can reach the stored +credential. + +The credential is stored per-connection in the existing `encrypted_extra` column +(`embedded_credentials: {username, password}` or +`{username, auth_method: "keypair", privatekey_body, privatekey_pass}`) and configured +through the connection dialog like any other connection secret. The passphrase is +optional. It is **not** configured in `superset_config.py`: +operators manage connections in the UI, and a credential in config could not be scoped +to one connection. + +### The gate + +One function — `Database.get_embedded_fallback_credentials()` — returns the credential +only when all five conditions hold: + +1. the deployment opted in via `EMBEDDED_CREDENTIAL_FALLBACK` (default `False`); +2. the engine opted in via `BaseEngineSpec.supports_embedded_credential_fallback` + (Snowflake is the only `True`); +3. the connection stores a **complete** credential — a half-filled one is ignored + rather than half-applied; +4. the current principal is an embedded guest **and** `EMBEDDED_SUPERSET` is enabled; +5. the connection has an OAuth2 client configured **and** `impersonate_user` enabled, + keeping this a fallback for the per-user flow rather than a second identity on an + ordinary connection. Deployment-wide client configuration is checked independently + of request context so restored async guests use the same credential. + +Condition 4 checks the feature flag explicitly rather than relying on +`get_current_guest_user_if_guest()`, which is a bare `isinstance` check. A `GuestUser` +can only reach `g` through the guest-token request loader, which is itself gated — but a +security boundary should not rest on that implication. + +Conditions 1, 2, 4 and 5 — everything except the stored credential — are also exposed on +their own as `Database.embedded_fallback_applies()`. That split is what lets +`_get_sqla_engine` tell two cases apart that otherwise look identical: *this request has +nothing to do with the fallback*, and *this is an embedded guest on a connection that has +no credential for them*. See **Refusing rather than falling back** below. + +### Refusing rather than falling back + +`_get_sqla_engine` applies the fallback *instead of* impersonation. An embedded viewer +on a per-user OAuth2 connection with no (or a half-filled) credential must not query +as the connection's base account. Neither the viewer nor the dashboard author can see +that identity change. Connections with impersonation disabled retain their ordinary +service-account behavior and do not use the fallback. + +With `EMBEDDED_CREDENTIAL_FALLBACK` on, that case raises a +`CONNECTION_ACCESS_DENIED_ERROR` instead. The refusal is scoped to the flag so that a +deployment which has not opted in keeps its existing behaviour. + +The connection dialog removes the ways to reach that state in the first place: the +section is offered only when both flags are on *and* the connection uses OAuth2, a +malformed or incomplete effective credential is rejected on save while both flags are +enabled, and a save with impersonation off is refused until the credential is +cleared, because it would be inactive. Form edits replace the stored credential before +completeness is checked. The save/test schemas and parameter-validation command use the +engine's `validate_embedded_credentials` hook, matching query-time rules even for custom credential shapes. The default rule +rejects whitespace-only usernames without trimming valid credential values. +Disabling either flag leaves stored credentials intact and skips completeness checks, +allowing unrelated edits to imported connections. The backend refusal is the backstop +for the paths that do not go through the dialog. + +### Cache identity + +The fallback changes the connection identity without going through impersonation, and +the DATA cache key has no acting-user component of its own — +`add_impersonation_cache_key_if_needed` only contributes one under `CACHE_IMPERSONATION`, +`CACHE_QUERY_BY_USER` or `per_user_caching`. Left alone, an embedded guest and a +logged-in user would compute the same `query_cache_key` for a physical dataset with no +RLS and no Jinja, and read each other's rows. Because that key is also the `task_key` of +the SHARED async chart task, they would additionally dedup onto one task, letting +whichever arrived first choose the credential for both. + +So a guest-run query contributes the engine-provided key (by default, +`embedded:`) in a dedicated `embedded_credentials_key` field, +distinct from the logged-in `impersonation_key` even when their values match. This applies +unconditionally — this is correctness, not a caching preference. The key comes from the +stored credential rather than from `g.user.username`, which a guest token's minter +controls. All guests on the connection share one entry, which is correct: they run as one +database user, and guest-token RLS already separates them. + +A guest with missing or incomplete credentials contributes a separate +`embedded_credentials_missing` marker. That partition cannot reuse logged-in or +configured-fallback cache entries or async tasks, so warming the cache does not bypass +the refusal at engine creation. + +### Why impersonation is skipped, not extended + +On the fallback path `Database._get_sqla_engine` sets the configured username on the +SQLAlchemy URL, adds its password there when password authentication is selected, and +does **not** call `impersonate_user`. For key-pair authentication, the Snowflake engine +spec converts the stored PEM key into `connect_args.private_key`. Skipping impersonation +is deliberate and load-bearing twice over: + +1. An engine that opts in sets its OAuth2 authenticator during impersonation whether or + not a token exists. Skipping impersonation is what lets the stored credential + authenticate at all. +2. `get_effective_user` resolves to `g.user.username`, and a `GuestUser` carries + whatever username the guest token claims. Whoever mints the token controls that + claim, so routing the fallback through impersonation would let the token choose the + connection identity. A test pins this specifically: a token minted with + `ACCOUNTADMIN` does not reach the connection. + +After applying fallback credentials, `DB_CONNECTION_MUTATOR` receives the selected +connection username, keeping a hook that forwards that argument from reintroducing the +guest token's username claim. + +The engine cache key includes both the URL and the final engine kwargs. A password +changes the URL; a PEM key changes `connect_args`. Tests assert that a guest and a +logged-in user produce distinct cache entries. + +## Security model + +This is the part worth the most scrutiny, so it is stated plainly rather than left to be +discovered. + +`SECURITY.md` treats the embedded guest token as a distinct principal with no Superset +account and no database identity. This proposal grants that principal the ability to run +queries **as one operator-configured database user**, on connections where an +administrator has explicitly configured it, in deployments that have explicitly enabled +it. + +What that does and does not mean: + +- **Per-viewer database-side authorization does not apply.** Every embedded viewer of + that connection queries as the same database user, so warehouse-side row-level + security or masking policies keyed to the connecting user cannot distinguish viewers. + Per-viewer restrictions must come from Superset's own guest-token RLS rules, which + continue to apply unchanged. The documentation says this in the help text next to the + field, not only in prose. +- **It does not widen what a guest can reach in Superset.** Dashboard, chart and dataset + access are still governed entirely by the guest token's `resources` and the embedded + dashboard's configuration. This changes only which credential the query runs under. +- **It is not reachable by a logged-in user.** Conditions 1–5 are evaluated on every + engine creation, and the engine cache cannot leak a guest engine to a logged-in user. +- **An embedded viewer on a per-user OAuth2 connection cannot reach the base credential + either.** Previously a guest with no applicable credential connected with the URL's own + username and password. With the flag on, that is refused. See *Refusing rather than + falling back* above. +- **Guest results do not cross into logged-in results.** The fallback contributes its own + cache identity, so a shared DATA-cache entry — or a shared async task — cannot serve a + logged-in user rows fetched under the guest credential, or the reverse. Guests without + credentials also receive a separate partition. See *Cache + identity* above. +- **The credential is never exposed.** The password, PEM private key, and optional + passphrase are in Snowflake's `encrypted_extra_sensitive_fields`, so they mask to + `XXXXXXXXXX` on read and are restored from storage when the mask is re-saved. The + username is deliberately left visible so an administrator can audit which database + user embedded queries run as. + +The honest summary: this is the same trust decision an operator makes today when they +point a connection at a service account, narrowed to apply only to embedded guests and +only where explicitly configured. It does not create a new class of credential, and it +does not let a token holder choose an identity. + +## New or changed public interfaces + +- **New feature flag** `EMBEDDED_CREDENTIAL_FALLBACK`, default `False`. +- **New engine-spec attribute** `BaseEngineSpec.supports_embedded_credential_fallback`, + default `False`; `SnowflakeEngineSpec` overrides it to `True`. +- **New `Database` methods** `get_embedded_fallback_credentials()`, + `embedded_fallback_applies()` and `validated_embedded_credentials()`. +- **New `encrypted_extra` key** `embedded_credentials`, supporting password and key-pair + credentials, with their secrets added to Snowflake's sensitive-field paths. +- **New connection-dialog field** for engines that opt in, rendered only when both + `EMBEDDED_CREDENTIAL_FALLBACK` and `EMBEDDED_SUPERSET` are on and the connection uses + OAuth2. +- **New validation** rejecting malformed or incomplete `embedded_credentials` on + `POST`/`PUT /api/v1/database/`, on `validate_parameters`, and on database and asset + import (HTTP 400) while both embedding flags are enabled. Only the import schema stays + loose, so existing exports still load with either flag off. + +No database migration: the credential lives in the existing `encrypted_extra` column. + +## Migration plan and compatibility + +Nothing changes for any existing deployment. The flag defaults to `False`; with it off, +`parameters_json_schema` does not offer the field, the gate never returns a credential, +and the refusal above never fires. + +Turning the flag off again is a kill switch rather than a deletion: a stored credential +stops being offered and stops being used, but is preserved, so the operation is +reversible and connection export/import round-trips unchanged. + +## Rejected alternatives + +- **A `superset_config.py` opt-in carrying the credential itself.** An earlier + implementation took this shape. It cannot scope a credential to one connection, hides + a security-relevant setting from the operators who manage connections, and makes + rotation a deploy. The feature flag proposed here is an on/off switch only; the + credential remains per-connection. +- **Reusing the `impersonate_user` machinery.** Rejected for the two reasons in *Why + impersonation is skipped* above — it would both defeat the mechanism and let the guest + token choose the connection identity. +- **A second connection for embedded use.** Available today and does not need a SIP, but + duplicates all connection configuration and offers no guard against an internal + dashboard being pointed at the service-account copy. +- **Minting real Superset users for embedded viewers.** Defeats the purpose of guest + tokens and does not help viewers with no warehouse identity. + +## Open questions + +1. **Should the opt-in be per-engine at all?** The mechanism in + `Database._get_sqla_engine` is generic; only the "skip impersonation so the + authenticator is not forced" reasoning is engine-specific. Snowflake is the only + `True` here because it is the only engine whose behaviour has been verified. Other + OAuth2 engines — Databricks, Trino, GSheets — could opt in once someone checks their + impersonation path. +2. **Should a guest-run query be marked as such in the query log**, so that an operator + auditing warehouse activity can tell shared-credential queries from per-user ones + from the Superset side? +3. **The Snowflake handshake itself is unverified.** Everything up to the connection URL + and engine kwargs is proven by test. Whether Snowflake accepts the credential on a + connection whose client is configured for OAuth2 needs an account with an OAuth2 + security integration. + Confirmation from anyone who has one would be welcome. diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index fd2f9ce7fadd..24cb3b9dd669 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -189,6 +189,13 @@ "lifecycle": "testing", "description": "Enable dynamic plugin loading" }, + { + "name": "EMBEDDED_CREDENTIAL_FALLBACK", + "default": false, + "lifecycle": "testing", + "description": "Allow a database connection to carry a username with a password or key pair used only for embedded guest requests, while logged-in users continue to authenticate per-user via OAuth2. Requires EMBEDDED_SUPERSET.", + "category": "security" + }, { "name": "ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT", "default": false, diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index 0fb99404ba1c..86e9d876c50e 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -17248,6 +17248,10 @@ "multipart/form-data": { "schema": { "properties": { + "encrypted_extra_secrets": { + "description": "JSON map of sensitive values for masked_encrypted_extra fields of featured databases. Keys are file paths (e.g., \"databases/db.yaml\") and values are JSON objects mapping JSONPath expressions to secrets.", + "type": "string" + }, "formData": { "description": "upload file (ZIP)", "format": "binary", @@ -19632,6 +19636,10 @@ "multipart/form-data": { "schema": { "properties": { + "encrypted_extra_secrets": { + "description": "JSON map of sensitive values for masked_encrypted_extra fields of featured databases. Keys are file paths (e.g., \"databases/db.yaml\") and values are JSON objects mapping JSONPath expressions to secrets.", + "type": "string" + }, "formData": { "description": "upload file (ZIP or JSON)", "format": "binary", @@ -25016,6 +25024,10 @@ "multipart/form-data": { "schema": { "properties": { + "encrypted_extra_secrets": { + "description": "JSON map of sensitive values for masked_encrypted_extra fields of featured databases. Keys are file paths (e.g., \"databases/db.yaml\") and values are JSON objects mapping JSONPath expressions to secrets.", + "type": "string" + }, "formData": { "description": "upload file (ZIP or YAML)", "format": "binary", @@ -30805,6 +30817,10 @@ "multipart/form-data": { "schema": { "properties": { + "encrypted_extra_secrets": { + "description": "JSON map of sensitive values for masked_encrypted_extra fields of featured databases. Keys are file paths (e.g., \"databases/db.yaml\") and values are JSON objects mapping JSONPath expressions to secrets.", + "type": "string" + }, "formData": { "description": "upload file (ZIP)", "format": "binary", diff --git a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx index 63f15de94ba7..bd09a12cf1c7 100644 --- a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx +++ b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx @@ -158,6 +158,29 @@ test('should render encrypted extra secret fields when needed for import', () => expect(getByTestId('encrypted_extra_secret')).toBeInTheDocument(); }); +test('should keep the newlines of a private key but hide other secrets', () => { + const { getAllByTestId } = setup({ + encryptedExtraFields: [ + { + fileName: 'databases/examples.yaml', + fields: [ + { + path: '$.embedded_credentials.privatekey_body', + label: 'Embedded Fallback Private Key', + }, + { + path: '$.embedded_credentials.privatekey_pass', + label: 'Embedded Fallback Private Key Password', + }, + ], + }, + ], + }); + const [key, passphrase] = getAllByTestId('encrypted_extra_secret'); + expect(key.tagName).toBe('TEXTAREA'); + expect(passphrase).toHaveAttribute('type', 'password'); +}); + test('should render multiple encrypted extra secret fields for multiple files', () => { const { getAllByTestId } = setup({ encryptedExtraFields: [ diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index 704473d78ef8..842bdb440dde 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -29,6 +29,7 @@ import { Button, Input, Checkbox, Modal } from '@superset-ui/core/components'; import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon'; import { ImportErrorAlert } from './ImportErrorAlert'; import type { ImportModelsModalProps } from './types'; +import isPrivateKeyField from './isPrivateKeyField'; const HelperMessage = styled.div` display: block; @@ -351,20 +352,39 @@ export const ImportModal: FunctionComponent = ({ {field.label} * - - setEncryptedExtraSecrets({ - ...encryptedExtraSecrets, - [fileName]: { - ...encryptedExtraSecrets[fileName], - [field.path]: event.target.value, - }, - }) - } - data-test="encrypted_extra_secret" - /> + {/* A PEM key is unusable without its newlines. */} + {isPrivateKeyField(field.path) ? ( + + setEncryptedExtraSecrets({ + ...encryptedExtraSecrets, + [fileName]: { + ...encryptedExtraSecrets[fileName], + [field.path]: event.target.value, + }, + }) + } + data-test="encrypted_extra_secret" + /> + ) : ( + + setEncryptedExtraSecrets({ + ...encryptedExtraSecrets, + [fileName]: { + ...encryptedExtraSecrets[fileName], + [field.path]: event.target.value, + }, + }) + } + data-test="encrypted_extra_secret" + /> + )} ))} diff --git a/superset-frontend/src/components/ImportModal/isPrivateKeyField.ts b/superset-frontend/src/components/ImportModal/isPrivateKeyField.ts new file mode 100644 index 000000000000..877b7aed768f --- /dev/null +++ b/superset-frontend/src/components/ImportModal/isPrivateKeyField.ts @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** A PEM private key in Secure Extra, which is unusable without its newlines. */ +export default function isPrivateKeyField(path: string): boolean { + return /private_?key(_body)?$/.test(path); +} diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/DatabaseConnectionForm.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/DatabaseConnectionForm.test.tsx index 0c7ef6b683d1..b44cf51983b2 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/DatabaseConnectionForm.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/DatabaseConnectionForm.test.tsx @@ -76,8 +76,7 @@ test('computeInitialIsPublic: returns false when parameters.oauth2_client_info i computeInitialIsPublic({ ...baseDb, parameters: { - // oauth2_client_info isn't in DatabaseParameters typing yet; this - // mirrors how an OAuth2-only edit-mode payload can arrive. + // Mirrors how an OAuth2-only edit-mode payload can arrive. oauth2_client_info: { id: 'client-id' }, } as DatabaseObject['parameters'], }), diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EmbeddedCredentialsField.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EmbeddedCredentialsField.test.tsx new file mode 100644 index 000000000000..c228f662ae20 --- /dev/null +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EmbeddedCredentialsField.test.tsx @@ -0,0 +1,567 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, fireEvent } from 'spec/helpers/testing-library'; +import { DatabaseObject } from 'src/features/databases/types'; +import { EmbeddedCredentialsField } from './EmbeddedCredentialsField'; + +const mockChangeMethods = { + onEncryptedExtraInputChange: jest.fn(), + onClearEncryptedExtraKey: jest.fn(), + onParametersChange: jest.fn(), + onChange: jest.fn(), + onQueryChange: jest.fn(), + onParametersUploadFileChange: jest.fn(), + onAddTableCatalog: jest.fn(), + onRemoveTableCatalog: jest.fn(), + onExtraInputChange: jest.fn(), + onSSHTunnelParametersChange: jest.fn(), +}; + +const OAUTH2_CLIENT_INFO = { + id: 'client-id', + secret: 'XXXXXXXXXX', +}; + +const defaultProps = { + required: false, + onParametersChange: jest.fn(), + onParametersUploadFileChange: jest.fn(), + changeMethods: mockChangeMethods, + validationErrors: null, + getValidation: jest.fn(), + clearValidationErrors: jest.fn(), + field: 'embedded_credentials', + isValidating: false, + db: { + configuration_method: 'dynamic_form', + database_name: 'test', + driver: 'snowflake', + engine: 'snowflake', + id: 1, + name: 'test', + is_managed_externally: false, + impersonate_user: true, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: OAUTH2_CLIENT_INFO, + embedded_credentials: { + username: 'embedded_svc', + password: 'XXXXXXXXXX', + }, + }), + } as DatabaseObject, +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('does not show input fields until the collapse trigger is clicked', () => { + const { getByText, getByTestId, queryByTestId } = render( + , + ); + + expect( + queryByTestId('embedded-credentials-username'), + ).not.toBeInTheDocument(); + expect( + queryByTestId('embedded-credentials-password'), + ).not.toBeInTheDocument(); + + fireEvent.click(getByText('Embedded guest credentials')); + + expect(getByTestId('embedded-credentials-username')).toBeInTheDocument(); + expect(getByTestId('embedded-credentials-password')).toBeInTheDocument(); + expect( + queryByTestId('embedded-credentials-private-key'), + ).not.toBeInTheDocument(); +}); + +test('renders the stored username and the masked password', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + + // The username stays readable so an admin can see who embedded queries run + // as; the password comes back masked and is posted back verbatim. + expect(getByTestId('embedded-credentials-username')).toHaveValue( + 'embedded_svc', + ); + expect(getByTestId('embedded-credentials-password')).toHaveValue( + 'XXXXXXXXXX', + ); +}); + +test('warns that every embedded viewer shares the credential', () => { + const { getByText } = render(); + + fireEvent.click(getByText('Embedded guest credentials')); + + expect( + getByText(/Every embedded viewer of this connection queries as this one/), + ).toBeInTheDocument(); +}); + +test('reports changes as an object so the parent can encrypt it', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.change(getByTestId('embedded-credentials-username'), { + target: { value: 'other_svc' }, + }); + + expect(mockChangeMethods.onParametersChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + type: 'object', + name: 'embedded_credentials', + value: { + username: 'other_svc', + password: 'XXXXXXXXXX', + auth_method: 'password', + }, + }, + }), + ); +}); + +test('uses a PEM key without requiring the embedded password', () => { + const { getByText, getByTestId, queryByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.click(getByText('Key pair')); + + expect( + queryByTestId('embedded-credentials-password'), + ).not.toBeInTheDocument(); + fireEvent.change(getByTestId('embedded-credentials-private-key'), { + target: { + value: '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----', + }, + }); + + expect(mockChangeMethods.onParametersChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + name: 'embedded_credentials', + value: expect.objectContaining({ + username: 'embedded_svc', + auth_method: 'keypair', + privatekey_body: + '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----', + }), + }), + }), + ); + expect( + mockChangeMethods.onParametersChange.mock.lastCall?.[0].target.value, + ).not.toHaveProperty('password'); +}); + +test('reuses a masked embedded PEM key and passphrase on edit', () => { + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: OAUTH2_CLIENT_INFO, + embedded_credentials: { + username: 'embedded_svc', + auth_method: 'keypair', + privatekey_body: 'XXXXXXXXXX', + privatekey_pass: 'XXXXXXXXXX', + }, + }), + }, + }; + const { getByText, getByTestId, queryByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + expect( + queryByTestId('embedded-credentials-password'), + ).not.toBeInTheDocument(); + expect(getByTestId('embedded-credentials-private-key')).toHaveValue( + 'XXXXXXXXXX', + ); + expect(getByTestId('embedded-credentials-key-passphrase')).toHaveValue( + 'XXXXXXXXXX', + ); +}); + +test('renders empty fields when no credential is stored', () => { + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: OAUTH2_CLIENT_INFO, + }), + } as DatabaseObject, + }; + + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + + expect(getByTestId('embedded-credentials-username')).toHaveValue(''); + expect(getByTestId('embedded-credentials-password')).toHaveValue(''); +}); + +test('keeps a blank key-pair selection when the parent clears the credential', () => { + const db = { + ...defaultProps.db, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: OAUTH2_CLIENT_INFO, + }), + }; + const { getByText, getByTestId, getByRole, queryByTestId, rerender } = render( + , + ); + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.click(getByText('Key pair')); + expect(mockChangeMethods.onParametersChange).toHaveBeenLastCalledWith({ + target: { name: 'embedded_credentials', value: '' }, + }); + + rerender( + , + ); + expect(getByRole('radio', { name: 'Key pair' })).toBeChecked(); + expect(getByTestId('embedded-credentials-private-key')).toBeInTheDocument(); + expect( + queryByTestId('embedded-credentials-password'), + ).not.toBeInTheDocument(); + + fireEvent.change(getByTestId('embedded-credentials-username'), { + target: { value: 'embedded_svc' }, + }); + expect(mockChangeMethods.onParametersChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: expect.objectContaining({ auth_method: 'keypair' }), + }), + }), + ); + + rerender(); + expect(getByTestId('embedded-credentials-username')).toHaveValue(''); + expect(getByRole('radio', { name: 'Key pair' })).toBeChecked(); + rerender( + , + ); + expect(getByRole('radio', { name: 'Password' })).toBeChecked(); +}); + +test.each([ + ['the literal string "null"', 'null'], + ['malformed JSON', 'not json'], + ['a JSON primitive', '42'], + ['a JSON array', '[1, 2, 3]'], +])('mounts safely when masked_encrypted_extra is %s', (_label, value) => { + const props = { + ...defaultProps, + db: { ...defaultProps.db, masked_encrypted_extra: value }, + // Without this the component correctly renders nothing, which would make + // these cases vacuous -- the point is that the parse itself cannot throw. + default_value: { engine_oauth2_client_configured: true }, + }; + + expect(() => render()).not.toThrow(); +}); + +test('is disabled with a reason when the connection has no OAuth2 client', () => { + const props = { + ...defaultProps, + db: { ...defaultProps.db, masked_encrypted_extra: '{}' }, + }; + + const { getByText, getByTestId } = render( + , + ); + + expect(getByTestId('embedded-credentials-inactive-reason')).toHaveTextContent( + 'Requires OAuth2 client information and “Impersonate logged in user”', + ); + fireEvent.click(getByText('Embedded guest credentials')); + expect(getByTestId('embedded-credentials-username')).toBeDisabled(); + expect(getByTestId('embedded-credentials-password')).toBeDisabled(); +}); + +test('is disabled with a reason when impersonation is off', () => { + const props = { + ...defaultProps, + db: { ...defaultProps.db, impersonate_user: false } as DatabaseObject, + }; + + const { getByText, getByTestId, getByRole } = render( + , + ); + + expect( + getByTestId('embedded-credentials-inactive-reason'), + ).toBeInTheDocument(); + fireEvent.click(getByText('Embedded guest credentials')); + expect(getByTestId('embedded-credentials-username')).toBeDisabled(); + expect(getByRole('radio', { name: 'Key pair' })).toBeDisabled(); + // A stored credential can still be removed while the section is inactive. + expect(getByTestId('embedded-credentials-remove')).toBeEnabled(); +}); + +test('is enabled without a reason when OAuth2 and impersonation are on', () => { + const { getByText, getByTestId, queryByTestId } = render( + , + ); + + expect( + queryByTestId('embedded-credentials-inactive-reason'), + ).not.toBeInTheDocument(); + fireEvent.click(getByText('Embedded guest credentials')); + expect(getByTestId('embedded-credentials-username')).toBeEnabled(); + expect(getByTestId('embedded-credentials-password')).toBeEnabled(); +}); + +test('uses a password input with a visibility toggle and no autofill', () => { + const { getByText, getByTestId, getAllByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + + const password = getByTestId('embedded-credentials-password'); + expect(password).toHaveAttribute('type', 'password'); + expect(password).toHaveAttribute('autocomplete', 'new-password'); + expect(password).toHaveAttribute('placeholder'); + expect(getByTestId('embedded-credentials-username')).toHaveAttribute( + 'autocomplete', + 'off', + ); + expect(getAllByTestId('icon-eye').length).toBeGreaterThan(0); + + fireEvent.click(getAllByTestId('icon-eye')[0]); + expect(password).toHaveAttribute('type', 'text'); +}); + +test('shows a Configured tag when a credential is stored', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('embedded-credentials-configured')).toHaveTextContent( + 'Configured', + ); +}); + +test('does not show a Configured tag or Remove when nothing is stored', () => { + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: OAUTH2_CLIENT_INFO, + }), + } as DatabaseObject, + }; + const { getByText, queryByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + expect( + queryByTestId('embedded-credentials-configured'), + ).not.toBeInTheDocument(); + expect(queryByTestId('embedded-credentials-remove')).not.toBeInTheDocument(); +}); + +test('mirrors username edits into the Secure Extra JSON', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.change(getByTestId('embedded-credentials-username'), { + target: { value: 'other_svc' }, + }); + + expect( + mockChangeMethods.onEncryptedExtraInputChange, + ).toHaveBeenLastCalledWith({ + target: { + name: 'embedded_credentials', + value: { + username: 'other_svc', + auth_method: 'password', + password: 'XXXXXXXXXX', + }, + }, + }); +}); + +test('Remove deletes the credential from Secure Extra', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.click(getByTestId('embedded-credentials-remove')); + + expect(mockChangeMethods.onClearEncryptedExtraKey).toHaveBeenCalledWith( + 'embedded_credentials', + ); + expect(mockChangeMethods.onParametersChange).toHaveBeenLastCalledWith({ + target: { name: 'embedded_credentials', value: '' }, + }); + expect(mockChangeMethods.onEncryptedExtraInputChange).not.toHaveBeenCalled(); + expect(getByTestId('embedded-credentials-username')).toHaveValue(''); + expect(getByTestId('embedded-credentials-password')).toHaveValue(''); +}); + +test('is enabled when OAuth2 client info is being typed into the form', () => { + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + masked_encrypted_extra: '{}', + parameters: { oauth2_client_info: OAUTH2_CLIENT_INFO }, + } as unknown as DatabaseObject, + }; + + const { queryByTestId } = render(); + + expect( + queryByTestId('embedded-credentials-inactive-reason'), + ).not.toBeInTheDocument(); +}); + +test('is enabled when the engine has a deployment-wide OAuth2 client', () => { + const props = { + ...defaultProps, + db: { ...defaultProps.db, masked_encrypted_extra: '{}' }, + default_value: { engine_oauth2_client_configured: true }, + }; + + const { queryByTestId } = render(); + + expect( + queryByTestId('embedded-credentials-inactive-reason'), + ).not.toBeInTheDocument(); +}); + +test('prefers an in-flight credential over the stored blob', () => { + // Switching the configuration method and back re-mounts the field while + // `parameters` still holds what was typed. Seeding from the stored blob + // alone would render empty inputs for a credential the save would carry. + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + parameters: { + embedded_credentials: { + username: 'typed_svc', + auth_method: 'password', + password: 'typed_pwd', + }, + }, + } as unknown as DatabaseObject, + }; + + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + + expect(getByTestId('embedded-credentials-username')).toHaveValue('typed_svc'); + expect(getByTestId('embedded-credentials-password')).toHaveValue('typed_pwd'); +}); + +test('clears the stored key instead of saving a blank credential', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.change(getByTestId('embedded-credentials-password'), { + target: { value: '' }, + }); + fireEvent.change(getByTestId('embedded-credentials-username'), { + target: { value: '' }, + }); + + expect(mockChangeMethods.onClearEncryptedExtraKey).toHaveBeenCalledWith( + 'embedded_credentials', + ); + // `auth_method` on its own is not a credential, so the parameter is cleared + // rather than posted as a shape full of empty strings. + expect(mockChangeMethods.onParametersChange).toHaveBeenLastCalledWith({ + target: { name: 'embedded_credentials', value: '' }, + }); +}); + +test('validates on blur so an incomplete credential surfaces before save', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByText('Embedded guest credentials')); + fireEvent.blur(getByTestId('embedded-credentials-username')); + + expect(defaultProps.getValidation).toHaveBeenCalled(); +}); + +test('opens the panel and shows the error when validation rejects the pair', () => { + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: OAUTH2_CLIENT_INFO, + embedded_credentials: { username: 'embedded_svc' }, + }), + } as DatabaseObject, + validationErrors: { + embedded_credentials: 'Embedded guest credentials are incomplete.', + }, + }; + + const { getByTestId } = render(); + + // No click: the panel opens itself, otherwise the error would be hidden + // behind a collapsed header while Connect sits disabled. + expect(getByTestId('embedded-credentials-error')).toHaveTextContent( + 'Embedded guest credentials are incomplete.', + ); +}); diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EmbeddedCredentialsField.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EmbeddedCredentialsField.tsx new file mode 100644 index 000000000000..46f60edd58b8 --- /dev/null +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EmbeddedCredentialsField.tsx @@ -0,0 +1,432 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ChangeEvent, useEffect, useRef, useState } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { Alert } from '@apache-superset/core/components'; +import { + Button, + Collapse, + Flex, + Form, + FormItem, + Input, + LabeledErrorBoundInput as ValidatedInput, + Tag, + Typography, +} from '@superset-ui/core/components'; +import { Radio } from '@superset-ui/core/components/Radio'; +import { + CustomParametersChangeType, + DatabaseObject, + FieldPropTypes, +} from '../../types'; + +const PANEL_KEY = 'embedded-credentials'; +const FIELD_NAME = 'embedded_credentials'; + +const LABELS = { + USERNAME: t('Username'), + PASSWORD: t('Password'), + PRIVATE_KEY: t('Private key (PEM)'), + PRIVATE_KEY_PASSPHRASE: t('Private key passphrase (optional)'), +}; + +// Stated here rather than only in the docs: an admin who reads this as a +// general-purpose fallback has made a security mistake. +const HELP_TEXT = t( + 'Used only when a dashboard is viewed through an embedded guest token. ' + + 'Every embedded viewer of this connection queries as this one user, so it ' + + 'should be a dedicated account with the narrowest access those dashboards ' + + 'need. Logged-in users are unaffected and continue to authenticate ' + + 'individually via OAuth2.', +); + +const INACTIVE_REASON = t( + 'Requires OAuth2 client information and “Impersonate logged in user” ' + + '(Advanced → Security).', +); + +interface EmbeddedCredentials { + username: string; + password: string; + auth_method: 'password' | 'keypair'; + privatekey_body: string; + privatekey_pass: string; +} + +/** + * Coerce a backend- or form-supplied value to a plain object. + * + * Both `masked_encrypted_extra` and the `parameters.*` entries arrive either as + * an object or as a JSON string -- the save-time merge in `DatabaseModal` + * re-serializes `parameters.*` in place -- and `masked_encrypted_extra` has + * historically been the literal string "null". `JSON.parse('null')` returns + * null and malformed JSON throws, so both are absorbed here rather than at each + * call site. + */ +const asObject = (value: unknown): Record => { + if (typeof value === 'string') { + let parsed: unknown; + try { + parsed = JSON.parse(value || '{}'); + } catch (e) { + // Only swallow JSON.parse's own SyntaxError; let real programmer errors + // through. + if (!(e instanceof SyntaxError)) throw e; + return {}; + } + return asObject(parsed); + } + + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +}; + +const asString = (value: unknown): string => + typeof value === 'string' ? value : ''; + +/** + * Does this connection have an OAuth2 client configured? + * + * The per-connection half is live form state (the admin may be filling the + * OAuth2 section during setup, so `parameters` is checked ahead of the stored + * blob); the deployment-wide half -- a client in `DATABASE_OAUTH2_CLIENTS` -- + * is knowable only server-side and arrives through the schema property's + * `default`. + */ +export const connectionUsesOAuth2 = ( + db?: DatabaseObject, + defaultValue?: { engine_oauth2_client_configured?: boolean }, +): boolean => { + if (defaultValue?.engine_oauth2_client_configured) { + return true; + } + + const fromForm = asObject(db?.parameters?.oauth2_client_info); + const fromStorage = asObject( + asObject(db?.masked_encrypted_extra).oauth2_client_info, + ); + + return !!(asString(fromForm.id) || asString(fromStorage.id)); +}; + +export const EmbeddedCredentialsField = ({ + changeMethods, + db, + default_value: defaultValue, + getValidation, + validationErrors, +}: FieldPropTypes) => { + const deriveEmbeddedCredentials = ( + blankAuthMethod: EmbeddedCredentials['auth_method'] = 'password', + ): EmbeddedCredentials => { + // The in-flight form value wins over the stored blob. Switching the + // configuration method to SQLAlchemy URI and back re-mounts this field + // while `parameters.embedded_credentials` still holds what was typed; + // seeding from `masked_encrypted_extra` alone would show empty inputs for + // a credential the save payload would nonetheless carry. + const typed = asObject(db?.parameters?.embedded_credentials); + const stored = asObject( + asObject(db?.masked_encrypted_extra).embedded_credentials, + ); + const source = Object.keys(typed).length ? typed : stored; + + return { + username: asString(source.username), + auth_method: Object.keys(source).length + ? source.auth_method === 'keypair' + ? 'keypair' + : 'password' + : blankAuthMethod, + // On edit these are the backend's masks. They are posted back verbatim + // and `unmask_encrypted_extra` restores the stored secrets. + password: asString(source.password), + privatekey_body: asString(source.privatekey_body), + privatekey_pass: asString(source.privatekey_pass), + }; + }; + + const [embeddedCredentials, setEmbeddedCredentials] = + useState(() => deriveEmbeddedCredentials()); + const [activeKeys, setActiveKeys] = useState([]); + const databaseIdRef = useRef(db?.id); + + // Serialized so the effect below keys off the *value*: the reducer hands back + // a fresh object on every keystroke, which as a raw dependency would re-sync + // state with itself on each one. + const typedCredentials = JSON.stringify( + db?.parameters?.embedded_credentials ?? null, + ); + + useEffect(() => { + const databaseChanged = databaseIdRef.current !== db?.id; + databaseIdRef.current = db?.id; + // Clearing the parent credential must not reset a blank form's selector. + setEmbeddedCredentials(previous => + deriveEmbeddedCredentials( + databaseChanged ? 'password' : previous.auth_method, + ), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-sync only when + // the connection or one of the two sources changes. + }, [db?.id, db?.masked_encrypted_extra, typedCredentials]); + + const errorMessage = asString(validationErrors?.embedded_credentials); + + // The panel is collapsed by default, which would hide its own validation + // error. Open it when one arrives rather than leaving the user with a + // disabled Connect button and nothing to look at. + useEffect(() => { + if (errorMessage) { + setActiveKeys(keys => + keys.includes(PANEL_KEY) ? keys : [...keys, PANEL_KEY], + ); + } + }, [errorMessage]); + + // The backend refuses to save a credential unless the connection has an + // OAuth2 client and impersonation is on, so the fields are only editable + // then. The section stays visible either way so the requirement is + // discoverable, and a stored credential can still be removed. + const isActive = + connectionUsesOAuth2(db, defaultValue) && !!db?.impersonate_user; + const isStored = + FIELD_NAME in asObject(db?.masked_encrypted_extra) || + Object.keys(asObject(db?.parameters?.embedded_credentials)).length > 0; + + const clearCredentials = () => { + // Report "no credential" rather than a shape full of empty strings: the + // save-time merge treats any object as truthy and would persist it. Clear + // the in-flight parameter *and* the Secure Extra key, as the gsheets + // public/private toggle does. + changeMethods.onParametersChange({ + target: { name: FIELD_NAME, value: '' }, + }); + changeMethods.onClearEncryptedExtraKey(FIELD_NAME); + }; + + const updateCredentials = (updated: EmbeddedCredentials) => { + setEmbeddedCredentials(updated); + + const isBlank = !( + updated.username || + updated.password || + updated.privatekey_body || + updated.privatekey_pass + ); + + // `auth_method` alone is not a credential. + if (isBlank) { + clearCredentials(); + return; + } + + const selectedCredentials = + updated.auth_method === 'keypair' + ? { + username: updated.username, + auth_method: updated.auth_method, + privatekey_body: updated.privatekey_body, + ...(updated.privatekey_pass && { + privatekey_pass: updated.privatekey_pass, + }), + } + : { + username: updated.username, + auth_method: updated.auth_method, + password: updated.password, + }; + const event: CustomParametersChangeType = { + target: { + type: 'object', + name: FIELD_NAME, + value: selectedCredentials, + }, + }; + changeMethods.onParametersChange(event); + // Mirror the edit into Secure Extra so the JSON shown under Advanced → + // Security matches what will be saved. + changeMethods.onEncryptedExtraInputChange({ + target: { name: FIELD_NAME, value: selectedCredentials }, + }); + }; + + const removeCredentials = () => { + setEmbeddedCredentials(previous => ({ + ...previous, + username: '', + password: '', + privatekey_body: '', + privatekey_pass: '', + })); + clearCredentials(); + }; + + const handleChange = + (key: 'username' | 'password' | 'privatekey_body' | 'privatekey_pass') => + (e: ChangeEvent) => { + const updated = { + ...embeddedCredentials, + [key]: e.target.value, + }; + updateCredentials(updated); + }; + + const isKeypair = embeddedCredentials.auth_method === 'keypair'; + const hasCredential = !!( + embeddedCredentials.username || + embeddedCredentials.password || + embeddedCredentials.privatekey_body || + embeddedCredentials.privatekey_pass + ); + const errorStatus = (field: string) => + errorMessage && !field ? ('error' as const) : undefined; + const validationMethods = { onBlur: getValidation }; + + return ( + setActiveKeys(Array.isArray(keys) ? keys : [keys])} + items={[ + { + key: PANEL_KEY, + label: ( + + {t('Embedded guest credentials')} + {!isActive && ( + + {INACTIVE_REASON} + + )} + + ), + extra: isStored ? ( + + {t('Configured')} + + ) : undefined, + children: ( +
+ + {HELP_TEXT} + + {errorMessage && ( + + {errorMessage} + + )} + + + + updateCredentials({ + ...embeddedCredentials, + auth_method: value, + }) + } + > + {t('Password')} + {t('Key pair')} + + + {isKeypair ? ( + <> + + + + + + ) : ( + + )} + {(hasCredential || isStored) && ( + + )} + + ), + }, + ]} + /> + ); +}; + +export default EmbeddedCredentialsField; diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.test.tsx index 59b7ad6cc17a..a866d0c812fc 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.test.tsx @@ -125,6 +125,12 @@ describe('OAuth2ClientField', () => { const clientIdInput = getByTestId('client-id'); fireEvent.change(clientIdInput, { target: { value: 'new-id' } }); + expect(mockChangeMethods.onEncryptedExtraInputChange).toHaveBeenCalledWith({ + target: { + name: 'oauth2_client_info', + value: expect.objectContaining({ id: 'new-id' }), + }, + }); expect(mockChangeMethods.onParametersChange).toHaveBeenCalledWith( expect.objectContaining({ target: { @@ -197,6 +203,25 @@ describe('OAuth2ClientField', () => { expect(queryByText('OAuth2 client information')).not.toBeInTheDocument(); }); + test('renders for snowflake', () => { + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + engine: 'snowflake', + }, + }; + + const { getByText, getByTestId } = render(); + + // The only early return in this component is the gsheets one, so Snowflake + // renders as soon as its parameters schema advertises oauth2_client_info. + fireEvent.click(getByText('OAuth2 client information')); + + expect(getByTestId('client-id')).toHaveValue('test-id'); + expect(getByTestId('client-secret')).toHaveValue('test-secret'); + }); + test('renders normally when engine is gsheets but isPublic is false', () => { const props = { ...defaultProps, @@ -246,6 +271,99 @@ describe('OAuth2ClientField', () => { expect(getByTestId('client-secret')).toHaveValue(''); }); + test('prefers an in-flight client over the stored blob', () => { + // After a switch to the SQLAlchemy URI form and back, this field re-mounts + // while the typed client still sits in `parameters.oauth2_client_info`. + // Seeding from `masked_encrypted_extra` alone would show empty inputs for + // a client the save payload would nonetheless carry. + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + parameters: { + oauth2_client_info: { id: 'typed-id', secret: 'typed-secret' }, + }, + } as DatabaseObject, + }; + + const { getByText, getByTestId } = render(); + fireEvent.click(getByText('OAuth2 client information')); + + expect(getByTestId('client-id')).toHaveValue('typed-id'); + expect(getByTestId('client-secret')).toHaveValue('typed-secret'); + }); + + test('reads an in-flight client that a save attempt has stringified', () => { + // `onSave` re-serializes every `x-encrypted-extra` parameter in place, so + // a second pass through this field sees a JSON string where the first saw + // an object. + const props = { + ...defaultProps, + db: { + ...defaultProps.db, + parameters: { + oauth2_client_info: JSON.stringify({ id: 'stringified-id' }), + } as unknown as DatabaseObject['parameters'], + } as DatabaseObject, + }; + + const { getByText, getByTestId } = render(); + fireEvent.click(getByText('OAuth2 client information')); + + expect(getByTestId('client-id')).toHaveValue('stringified-id'); + }); + + test.each([ + ['authorization_request_uri', 'client-authorization-request-uri'], + ['token_request_uri', 'client-token-request-uri'], + ['scope', 'client-scope'], + ])( + 'keeps an explicitly cleared %s empty after a parent update', + (field, id) => { + const defaults = { + authorization_request_uri: 'https://accounts.google.com/o/oauth2/auth', + token_request_uri: 'https://oauth2.googleapis.com/token', + scope: 'https://www.googleapis.com/auth/spreadsheets', + }; + const props = { + ...defaultProps, + default_value: defaults, + isPublic: false, + db: { + ...defaultProps.db, + engine: 'gsheets', + masked_encrypted_extra: '{}', + }, + }; + const { getByTestId, getByText, rerender } = render( + , + ); + fireEvent.click(getByText('OAuth2 client information')); + expect(getByTestId(id)).not.toHaveValue(''); + + fireEvent.change(getByTestId(id), { target: { value: '' } }); + const submittedInfo = { id: '', secret: '', ...defaults, [field]: '' }; + expect(mockChangeMethods.onParametersChange).toHaveBeenLastCalledWith({ + target: { + type: 'object', + name: 'oauth2_client_info', + value: submittedInfo, + }, + }); + + rerender( + , + ); + expect(getByTestId(id)).toHaveValue(''); + }, + ); + test.each([ ['the literal string "null"', 'null'], ['malformed JSON', 'not json'], diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.tsx index f87d22c3b9ee..8b7a8ef02bbb 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/OAuth2ClientField.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useState } from 'react'; +import { ChangeEvent, useEffect, useState } from 'react'; import { t } from '@apache-superset/core/translation'; import { Input, Collapse, Form, FormItem } from '@superset-ui/core/components'; import { @@ -42,6 +42,34 @@ interface OAuth2ClientInfo { scope: string; } +/** + * Coerce a backend- or form-supplied value to a plain object. + * + * `masked_encrypted_extra` and the `parameters.*` entries arrive either as an + * object or as a JSON string -- the save-time merge in `DatabaseModal` + * re-serializes `parameters.*` in place -- and `masked_encrypted_extra` has + * historically been the literal string "null". `JSON.parse('null')` returns + * null and malformed JSON throws, so both are absorbed here. + */ +const asObject = (value: unknown): Record => { + if (typeof value === 'string') { + let parsed: unknown; + try { + parsed = JSON.parse(value || '{}'); + } catch (e) { + // Only swallow JSON.parse's own SyntaxError; let real programmer errors + // (RangeError, TypeError from a broken stub, etc.) propagate. + if (!(e instanceof SyntaxError)) throw e; + return {}; + } + return asObject(parsed); + } + + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +}; + export const OAuth2ClientField = ({ changeMethods, db, @@ -49,34 +77,29 @@ export const OAuth2ClientField = ({ isPublic = true, }: FieldPropTypes) => { const deriveOauth2ClientInfo = (): OAuth2ClientInfo => { - // `masked_encrypted_extra` is user/backend-supplied and historically - // sometimes the string "null" — JSON.parse('null') returns null, and - // malformed JSON throws. Defend against both so a single bad value - // can't crash the component. - let parsed: unknown; - try { - parsed = JSON.parse(db?.masked_encrypted_extra || '{}'); - } catch (e) { - // Only swallow JSON.parse's own SyntaxError; let real programmer - // errors (RangeError, TypeError from a broken stub, etc.) propagate. - if (!(e instanceof SyntaxError)) throw e; - parsed = {}; - } - const encryptedExtra = - parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as { oauth2_client_info?: Partial }) - : {}; - const info = encryptedExtra.oauth2_client_info; + // The in-flight form value wins over the stored blob. Switching the + // configuration method to SQLAlchemy URI and back re-mounts this field + // while `parameters.oauth2_client_info` still holds what was typed; + // seeding from `masked_encrypted_extra` alone would show empty inputs for + // a client the save payload would nonetheless carry. + const typed = asObject(db?.parameters?.oauth2_client_info); + const stored = asObject( + asObject(db?.masked_encrypted_extra).oauth2_client_info, + ); + const info: Partial = ( + Object.keys(typed).length ? typed : stored + ) as Partial; + return { - id: info?.id || '', - secret: info?.secret || '', + id: info.id || '', + secret: info.secret || '', authorization_request_uri: - info?.authorization_request_uri || - defaultValue?.authorization_request_uri || + info.authorization_request_uri ?? + defaultValue?.authorization_request_uri ?? '', token_request_uri: - info?.token_request_uri || defaultValue?.token_request_uri || '', - scope: info?.scope || defaultValue?.scope || '', + info.token_request_uri ?? defaultValue?.token_request_uri ?? '', + scope: info.scope ?? defaultValue?.scope ?? '', }; }; @@ -84,36 +107,49 @@ export const OAuth2ClientField = ({ deriveOauth2ClientInfo, ); - // Re-sync local state when masked_encrypted_extra changes (e.g., when the - // gsheets dropdown toggles back to private after we cleared stored creds). + // Serialized so the effect below keys off the *value*: the reducer hands back + // a fresh object on every keystroke, which as a raw dependency would re-sync + // state with itself on each one. + const typedClientInfo = JSON.stringify( + db?.parameters?.oauth2_client_info ?? null, + ); + + // Re-sync local state when either source changes (e.g., when the gsheets + // dropdown toggles back to private after we cleared stored creds). useEffect(() => { setOauth2ClientInfo(deriveOauth2ClientInfo()); // eslint-disable-next-line react-hooks/exhaustive-deps -- `defaultValue` is a // static per-engine default that doesn't change after the form opens; // depending on it would cause spurious re-syncs when the parent re-renders. - }, [db?.masked_encrypted_extra]); + }, [db?.masked_encrypted_extra, typedClientInfo]); if (db?.engine === Engines.GSheet && isPublic) { return null; } - const handleChange = (key: any) => (e: any) => { - const updatedInfo = { - ...oauth2ClientInfo, - [key]: e.target.value, - }; + const handleChange = + (key: keyof OAuth2ClientInfo) => (e: ChangeEvent) => { + const updatedInfo = { + ...oauth2ClientInfo, + [key]: e.target.value, + }; - setOauth2ClientInfo(updatedInfo); + setOauth2ClientInfo(updatedInfo); - const event: CustomParametersChangeType = { - target: { - type: 'object', - name: 'oauth2_client_info', - value: updatedInfo, - }, + const event: CustomParametersChangeType = { + target: { + type: 'object', + name: 'oauth2_client_info', + value: updatedInfo, + }, + }; + changeMethods.onParametersChange(event); + // Mirror the edit into Secure Extra: a later edit there retires the typed + // copy above, and must not take what was typed with it. + changeMethods.onEncryptedExtraInputChange({ + target: { name: 'oauth2_client_info', value: updatedInfo }, + }); }; - changeMethods.onParametersChange(event); - }; return ( - + diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 7f2d7e21be90..87169aa6e262 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -319,6 +319,61 @@ describe('DatabaseModal', () => { sqlalchemy_uri_placeholder: 'databricks+connector://token:{access_token}@{host}:{port}/{database_name}', }, + { + available_drivers: ['snowflake'], + default_driver: 'snowflake', + engine: 'snowflake', + name: 'Snowflake', + parameters: { + properties: { + account: { + type: 'string', + }, + database: { + type: 'string', + }, + password: { + type: 'string', + }, + role: { + type: 'string', + }, + username: { + type: 'string', + }, + warehouse: { + type: 'string', + }, + oauth2_client_info: { + description: 'OAuth2 client information', + type: 'string', + 'x-encrypted-extra': true, + }, + embedded_credentials: { + description: + 'Credentials used only for embedded guest requests', + type: 'string', + 'x-encrypted-extra': true, + }, + }, + required: [ + 'account', + 'database', + 'password', + 'role', + 'username', + 'warehouse', + ], + type: 'object', + }, + preferred: false, + sqlalchemy_uri_placeholder: 'snowflake://', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + supports_oauth2: true, + }, + }, ], }); fetchMock.post( @@ -2492,6 +2547,49 @@ describe('DatabaseModal', () => { expect(screen.getByTestId('sqlalchemy-uri-input')).toBeInTheDocument(); }); + test('keeps what was typed when the configuration method round-trips', async () => { + // Switching to the SQLAlchemy URI form and back used to replace the whole + // reducer state with {engine, configuration_method, database_name}, so + // every other field -- including the OAuth2 and embedded sub-forms, whose + // values live under `parameters` -- came back empty. + setup({ dbEngine: 'Snowflake' }); + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + await userEvent.type(fieldByName('account'), 'dummy-account'); + + await userEvent.click(screen.getByText('OAuth2 client information')); + await userEvent.type(await screen.findByTestId('client-id'), 'client-123'); + + // Impersonation cannot be turned on before the connection exists, so the + // embedded sub-form stays disabled during creation (the backend would + // refuse the credential anyway). Its round-trip is covered by + // EmbeddedCredentialsField's in-flight credential test. + await userEvent.click(screen.getByText('Embedded guest credentials')); + expect( + await screen.findByTestId('embedded-credentials-username'), + ).toBeDisabled(); + expect( + screen.getByTestId('embedded-credentials-inactive-reason'), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('sqla-connect-btn')); + expect( + await screen.findByTestId('sqlalchemy-uri-input'), + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: /dynamic form instead/i }), + ); + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + expect(fieldByName('account')).toHaveValue('dummy-account'); + + // The sub-form re-mounts collapsed, and re-reads its value from the + // parameters the switch no longer drops. + await userEvent.click(screen.getByText('OAuth2 client information')); + expect(await screen.findByTestId('client-id')).toHaveValue('client-123'); + }); + test.each([ { field: 'host', @@ -2568,6 +2666,42 @@ describe('DatabaseModal', () => { useSingleViewResourceMock.mockRestore(); }, ); + + test('keeps Connect disabled while a half-filled embedded credential is rejected', async () => { + setup(); + await selectPostgres(); + fillDynamicForm(); + + const submitButton = screen.getByTestId('btn-submit-connection'); + await userEvent.click(document.body); + await waitFor(() => expect(submitButton).toBeEnabled()); + + // `ValidateDatabaseParametersCommand._get_embedded_credentials_error` + // reports this shape. `INVALID_PAYLOAD_SCHEMA_ERROR` is on the blur-time + // allowlist in `useDatabaseValidation`, so the half-filled credential + // blocks the save without waiting for the user to press Connect. + fetchMock.modifyRoute('validate-params', { + response: { + status: 422, + body: { + errors: [ + { + message: + 'Embedded guest credentials need both a username and a password.', + error_type: 'INVALID_PAYLOAD_SCHEMA_ERROR', + level: 'error', + extra: { invalid: ['embedded_credentials'] }, + }, + ], + }, + }, + }); + + await userEvent.type(fieldByName('database'), 'x'); + await userEvent.click(document.body); + + await waitFor(() => expect(submitButton).toBeDisabled()); + }); }); test('handleChangeWithValidation function clears validation errors when called', () => { @@ -2653,12 +2787,99 @@ test('validates fix by testing all form field types clear validation errors', () // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('dbReducer', () => { + test('a Secure Extra edit retires the typed Secure Extra blocks', () => { + const typedState = { + ...databaseFixture, + parameters: { + host: 'account', + embedded_credentials: { username: 'svc', password: 'pw' }, + oauth2_client_info: { id: 'client', secret: 'typed' }, + }, + }; + const secureExtra = dbReducer(typedState, { + type: ActionType.EditorChange, + payload: { name: 'masked_encrypted_extra', json: '{}' }, + }); + expect(secureExtra?.masked_encrypted_extra).toEqual('{}'); + expect(secureExtra?.parameters).toEqual({ host: 'account' }); + + const otherEditor = dbReducer(typedState, { + type: ActionType.EditorChange, + payload: { name: 'server_cert', json: 'cert' }, + }); + expect(otherEditor?.parameters).toEqual(typedState.parameters); + }); + + test('a Secure Extra edit keeps an OAuth2 client typed in the sub-form', () => { + const typedClient = { id: 'B', secret: 'XXXXXXXXXX' }; + const mirrored = dbReducer( + { + ...databaseFixture, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: { id: 'A', secret: 'XXXXXXXXXX' }, + }), + parameters: { oauth2_client_info: typedClient }, + }, + { + type: ActionType.EncryptedExtraInputChange, + payload: { name: 'oauth2_client_info', value: typedClient }, + }, + ); + const edited = dbReducer(mirrored, { + type: ActionType.EditorChange, + payload: { + name: 'masked_encrypted_extra', + json: mirrored?.masked_encrypted_extra ?? '', + }, + }); + + expect(edited?.parameters?.oauth2_client_info).toBeUndefined(); + expect( + JSON.parse(edited?.masked_encrypted_extra ?? '{}').oauth2_client_info, + ).toEqual(typedClient); + }); + test('it will reset state to null', () => { const action: DBReducerActionType = { type: ActionType.Reset }; const currentState = dbReducer(databaseFixture, action); expect(currentState).toBeNull(); }); + test('it keeps everything typed so far when the configuration method changes', () => { + // The dialog's two mode-switch links hand back only the three keys the + // other mode needs. Everything else -- the parameters (which is where the + // OAuth2 and embedded sub-forms keep their values), the encrypted-extra + // blob and the advanced-tab fields -- has to survive, or a round trip + // silently empties the form. + const typedState: Partial = { + database_name: 'My database', + engine: 'snowflake', + driver: 'snowflake', + configuration_method: ConfigurationMethod.DynamicForm, + parameters: { + account: 'abc12345', + oauth2_client_info: { id: 'client-123' }, + embedded_credentials: { username: 'embedded_svc', password: 'pw' }, + } as DatabaseObject['parameters'], + masked_encrypted_extra: '{}', + extra: '{"allows_virtual_table_explore":true}', + expose_in_sqllab: true, + }; + const action: DBReducerActionType = { + type: ActionType.ConfigMethodChange, + payload: { + database_name: 'My database', + engine: 'snowflake', + configuration_method: ConfigurationMethod.SqlalchemyUri, + }, + }; + + expect(dbReducer(typedState, action)).toEqual({ + ...typedState, + configuration_method: ConfigurationMethod.SqlalchemyUri, + }); + }); + test('it will set state to payload from fetched', () => { const action: DBReducerActionType = { type: ActionType.Fetched, @@ -2749,6 +2970,33 @@ describe('dbReducer', () => { }); }); + test('EncryptedExtraInputChange replaces a nested embedded credential', () => { + // The embedded credentials sub-form mirrors each edit into Secure Extra so + // the JSON shown under Advanced → Security matches what will be saved. + const action: DBReducerActionType = { + type: ActionType.EncryptedExtraInputChange, + payload: { + name: 'embedded_credentials', + value: { username: 'new_svc', password: 'XXXXXXXXXX' }, + }, + }; + const currentState = dbReducer( + { + ...databaseFixture, + masked_encrypted_extra: JSON.stringify({ + oauth2_client_info: { id: 'client-id' }, + embedded_credentials: { username: 'old_svc', password: 'XXXXXXXXXX' }, + }), + }, + action, + ); + + expect(JSON.parse(currentState?.masked_encrypted_extra || '{}')).toEqual({ + oauth2_client_info: { id: 'client-id' }, + embedded_credentials: { username: 'new_svc', password: 'XXXXXXXXXX' }, + }); + }); + test('EncryptedExtraInputChange stores empty value verbatim (does not delete)', () => { const action: DBReducerActionType = { type: ActionType.EncryptedExtraInputChange, diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 2a90633f6ce3..6a80ef920f59 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -52,6 +52,7 @@ import { } from '@superset-ui/core/components'; import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components'; import withToasts from 'src/components/MessageToasts/withToasts'; +import isPrivateKeyField from 'src/components/ImportModal/isPrivateKeyField'; import { testDatabaseConnection, useSingleViewResource, @@ -199,7 +200,6 @@ export type DBReducerActionType = type: | ActionType.ExtraEditorChange | ActionType.ExtraInputChange - | ActionType.EncryptedExtraInputChange | ActionType.ClearEncryptedExtraKey | ActionType.TextChange | ActionType.QueryChange @@ -209,6 +209,14 @@ export type DBReducerActionType = | ActionType.ParametersSSHTunnelChange; payload: DBReducerPayloadType; } + | { + // Nested Secure Extra blocks (e.g. `embedded_credentials`) carry an + // object rather than a string. + type: ActionType.EncryptedExtraInputChange; + payload: Omit & { + value?: string | Record; + }; + } | { type: ActionType.Fetched; isEditMode?: boolean; @@ -553,11 +561,26 @@ export function dbReducer( return { ...trimmedState, }; - case ActionType.EditorChange: - return { + case ActionType.EditorChange: { + const edited = { ...trimmedState, [action.payload.name]: action.payload.json, }; + if ( + action.payload.name === 'masked_encrypted_extra' && + edited.parameters + ) { + // `onSave` copies these typed blocks over Secure Extra, which would undo + // this later edit of them. + const { + embedded_credentials: _typedCredentials, + oauth2_client_info: _typedClientInfo, + ...parameters + } = edited.parameters; + return { ...edited, parameters }; + } + return edited; + } case ActionType.QueryChange: return { ...trimmedState, @@ -642,8 +665,20 @@ export function dbReducer( expose_in_sqllab: true, }; case ActionType.ConfigMethodChange: + // Switching how a connection is described must not discard what has + // already been typed into it. The payload carries only the three keys + // the other mode needs; everything else -- the parameters, the + // encrypted-extra blob, the advanced-tab fields and the engine metadata + // the form needs to render itself -- is kept, so a round trip through + // the other mode returns the form in the state it left. Both callers + // read these three keys straight out of the current state, so an + // undefined value means "unset", never "clear what is there". return { - ...action.payload, + ...trimmedState, + configuration_method: action.payload.configuration_method, + database_name: + action.payload.database_name ?? trimmedState.database_name, + engine: action.payload.engine ?? trimmedState.engine, }; case ActionType.Reset: @@ -1799,7 +1834,8 @@ const DatabaseModal: FunctionComponent = ({ id={`encrypted_extra_${field.path}`} name={`encrypted_extra_${field.path}`} required - visibilityToggle + visibilityToggle={!isPrivateKeyField(field.path)} + renderAsTextArea={isPrivateKeyField(field.path)} value={encryptedExtraSecrets[fileName]?.[field.path] || ''} onChange={(event: ChangeEvent) => setEncryptedExtraSecrets({ diff --git a/superset-frontend/src/features/databases/types.ts b/superset-frontend/src/features/databases/types.ts index e07e8e03e97c..cc2a85266be1 100644 --- a/superset-frontend/src/features/databases/types.ts +++ b/superset-frontend/src/features/databases/types.ts @@ -62,6 +62,8 @@ export type DatabaseParameters = { account?: string; ssh?: boolean; project_id?: string; + oauth2_client_info?: Record; + embedded_credentials?: Record; }; export type DatabaseObject = { diff --git a/superset-frontend/src/features/datasets/constants.ts b/superset-frontend/src/features/datasets/constants.ts index 93f117b9a41a..1e767eb87443 100644 --- a/superset-frontend/src/features/datasets/constants.ts +++ b/superset-frontend/src/features/datasets/constants.ts @@ -23,9 +23,9 @@ export const SORT_BY = [{ id: 'changed_on_delta_humanized', desc: true }]; export const PASSWORDS_NEEDED_MESSAGE = t( 'The passwords for the databases below are needed in order to ' + 'import them together with the datasets. Please note that the ' + - '"Secure Extra" and "Certificate" sections of ' + - 'the database configuration are not present in export files, and ' + - 'should be added manually after the import if they are needed.', + '"Certificate" section of the database configuration is not ' + + 'present in export files, and should be added manually after the ' + + 'import if it is needed.', ); export const CONFIRM_OVERWRITE_MESSAGE = t( 'You are importing one or more datasets that already exist. ' + diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index b81a7f64804b..57d7a3da4014 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -92,7 +92,10 @@ import { loadTags } from 'src/components/Tag/utils'; import ChartCard from 'src/features/charts/ChartCard'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { findPermission } from 'src/utils/findPermission'; -import { QueryObjectColumns } from 'src/views/CRUD/types'; +import { + QueryObjectColumns, + FileEncryptedExtraFields, +} from 'src/views/CRUD/types'; import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils'; import { Tag } from 'src/components/Tag'; import { datasetLabel } from 'src/features/semanticLayers/label'; @@ -192,9 +195,9 @@ function ChartArchiveDescription({ const PASSWORDS_NEEDED_MESSAGE = t( 'The passwords for the databases below are needed in order to ' + 'import them together with the charts. Please note that the ' + - '"Secure Extra" and "Certificate" sections of ' + - 'the database configuration are not present in export files, and ' + - 'should be added manually after the import if they are needed.', + '"Certificate" section of the database configuration is not ' + + 'present in export files, and should be added manually after the ' + + 'import if it is needed.', ); const CONFIRM_OVERWRITE_MESSAGE = t( 'You are importing one or more charts that already exist. ' + @@ -331,6 +334,9 @@ function ChartList(props: ChartListProps) { sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState([]); + const [encryptedExtraFields, setEncryptedExtraFields] = useState< + FileEncryptedExtraFields[] + >([]); // TODO: Fix usage of localStorage keying on the user id const userSettings = useMemo( @@ -1184,6 +1190,8 @@ function ChartList(props: ChartListProps) { setSSHTunnelPrivateKeyPasswordFields={ setSSHTunnelPrivateKeyPasswordFields } + encryptedExtraFields={encryptedExtraFields} + setEncryptedExtraFields={setEncryptedExtraFields} /> {preparingExport && } diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index b307af27c288..7f5b2898d603 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -81,6 +81,7 @@ import Dashboard from 'src/dashboard/containers/Dashboard'; import { Dashboard as CRUDDashboard, QueryObjectColumns, + FileEncryptedExtraFields, } from 'src/views/CRUD/types'; import { TagTypeEnum } from 'src/components/Tag/TagType'; import { loadTags } from 'src/components/Tag/utils'; @@ -97,9 +98,9 @@ const PAGE_SIZE = 25; const PASSWORDS_NEEDED_MESSAGE = t( 'The passwords for the databases below are needed in order to ' + 'import them together with the dashboards. Please note that the ' + - '"Secure Extra" and "Certificate" sections of ' + - 'the database configuration are not present in export files, and ' + - 'should be added manually after the import if they are needed.', + '"Certificate" section of the database configuration is not ' + + 'present in export files, and should be added manually after the ' + + 'import if it is needed.', ); const CONFIRM_OVERWRITE_MESSAGE = t( 'You are importing one or more dashboards that already exist. ' + @@ -247,6 +248,9 @@ function DashboardList(props: DashboardListProps) { sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState([]); + const [encryptedExtraFields, setEncryptedExtraFields] = useState< + FileEncryptedExtraFields[] + >([]); const openDashboardImportModal = () => { showImportModal(true); @@ -1034,6 +1038,8 @@ function DashboardList(props: DashboardListProps) { setSSHTunnelPrivateKeyPasswordFields={ setSSHTunnelPrivateKeyPasswordFields } + encryptedExtraFields={encryptedExtraFields} + setEncryptedExtraFields={setEncryptedExtraFields} /> {preparingExport && } diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 4debcd3ee4db..4561054ba1d0 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -113,7 +113,10 @@ import { databaseLabel, } from 'src/features/semanticLayers/label'; import { useSelector } from 'react-redux'; -import { QueryObjectColumns } from 'src/views/CRUD/types'; +import { + QueryObjectColumns, + FileEncryptedExtraFields, +} from 'src/views/CRUD/types'; import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils'; import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon'; import SemanticDeleteBlockers, { @@ -667,6 +670,9 @@ const DatasetList: FunctionComponent = ({ sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState([]); + const [encryptedExtraFields, setEncryptedExtraFields] = useState< + FileEncryptedExtraFields[] + >([]); const PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = useSelector< BootstrapData, @@ -1959,6 +1965,8 @@ const DatasetList: FunctionComponent = ({ setSSHTunnelPrivateKeyPasswordFields={ setSSHTunnelPrivateKeyPasswordFields } + encryptedExtraFields={encryptedExtraFields} + setEncryptedExtraFields={setEncryptedExtraFields} /> {preparingExport && } diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index 2a603d2fe48e..d4e630db4a89 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -57,7 +57,11 @@ import { import handleResourceExport from 'src/utils/export'; import SubMenu, { ButtonProps, SubMenuProps } from 'src/features/home/SubMenu'; import { commonMenuData } from 'src/features/home/commonMenuData'; -import { QueryObjectColumns, SavedQueryObject } from 'src/views/CRUD/types'; +import { + QueryObjectColumns, + SavedQueryObject, + FileEncryptedExtraFields, +} from 'src/views/CRUD/types'; import { TagTypeEnum } from 'src/components/Tag/TagType'; import { loadTags } from 'src/components/Tag/utils'; import { Icons } from '@superset-ui/core/components/Icons'; @@ -71,9 +75,9 @@ const PAGE_SIZE = 25; const PASSWORDS_NEEDED_MESSAGE = t( 'The passwords for the databases below are needed in order to ' + 'import them together with the saved queries. Please note that the ' + - '"Secure Extra" and "Certificate" sections of ' + - 'the database configuration are not present in export files, and ' + - 'should be added manually after the import if they are needed.', + '"Certificate" section of the database configuration is not ' + + 'present in export files, and should be added manually after the ' + + 'import if it is needed.', ); const CONFIRM_OVERWRITE_MESSAGE = t( 'You are importing one or more saved queries that already exist. ' + @@ -155,6 +159,9 @@ function SavedQueryList({ sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState([]); + const [encryptedExtraFields, setEncryptedExtraFields] = useState< + FileEncryptedExtraFields[] + >([]); const history = useHistory(); const openSavedQueryImportModal = () => { @@ -739,6 +746,8 @@ function SavedQueryList({ setSSHTunnelPrivateKeyPasswordFields={ setSSHTunnelPrivateKeyPasswordFields } + encryptedExtraFields={encryptedExtraFields} + setEncryptedExtraFields={setEncryptedExtraFields} /> {preparingExport && } diff --git a/superset/charts/api.py b/superset/charts/api.py index 5f09d5466988..4dcc194578b6 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -1746,6 +1746,13 @@ def import_(self) -> Response: the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key_password"}`. type: string + encrypted_extra_secrets: + description: >- + JSON map of sensitive values for masked_encrypted_extra fields + of featured databases. Keys are file paths (e.g., + "databases/db.yaml") and values are JSON objects mapping + JSONPath expressions to secrets. + type: string responses: 200: description: Chart import result @@ -1783,6 +1790,7 @@ def import_(self) -> Response: ssh_tunnel_priv_key_passwords = parse_json_form_field( "ssh_tunnel_private_key_passwords" ) + encrypted_extra_secrets = parse_json_form_field("encrypted_extra_secrets") command = ImportChartsCommand( contents, @@ -1791,6 +1799,7 @@ def import_(self) -> Response: ssh_tunnel_passwords=ssh_tunnel_passwords, ssh_tunnel_private_keys=ssh_tunnel_private_keys, ssh_tunnel_priv_key_passwords=ssh_tunnel_priv_key_passwords, + encrypted_extra_secrets=encrypted_extra_secrets, ) command.run() return self.response(200, message="OK") diff --git a/superset/commands/chart/data/get_data_command.py b/superset/commands/chart/data/get_data_command.py index cd7b1abd67c6..9c0c4ff85717 100644 --- a/superset/commands/chart/data/get_data_command.py +++ b/superset/commands/chart/data/get_data_command.py @@ -18,6 +18,7 @@ from typing import Any from flask_babel import gettext as _ +from sqlalchemy.exc import SQLAlchemyError from superset.commands.base import BaseCommand from superset.commands.chart.exceptions import ( @@ -43,6 +44,22 @@ def __init__(self, query_context: QueryContext): def query_context(self) -> QueryContext: return self._query_context + def _query_failed(self, ex: SQLAlchemyError) -> ChartDataQueryFailedError: + """ + Report a database error raised while the query is being built, not run. + + A virtual dataset's default-schema lookup connects, so a rejected login + lands here; it is a failed query like any other, not an unexpected + server error. + """ + database = getattr(self._query_context.datasource, "database", None) + message = ( + database.db_engine_spec.extract_error_message(ex) + if database is not None + else str(ex) + ) + return ChartDataQueryFailedError(_("Error: %(error)s", error=message)) + def run(self, **kwargs: Any) -> dict[str, Any]: # caching is handled in query_context.get_df_payload # (also evals `force` property) @@ -54,6 +71,8 @@ def run(self, **kwargs: Any) -> dict[str, Any]: ) except CacheLoadError as ex: raise ChartDataCacheLoadError(ex.message) from ex + except SQLAlchemyError as ex: + raise self._query_failed(ex) from ex for query in payload["queries"]: self._raise_for_query_error(query) @@ -78,6 +97,8 @@ def execute(self, **kwargs: Any) -> ChartDataExecutionResult: ) except CacheLoadError as ex: raise ChartDataCacheLoadError(ex.message) from ex + except SQLAlchemyError as ex: + raise self._query_failed(ex) from ex for query in result.queries: self._raise_for_query_error(query.payload) diff --git a/superset/commands/chart/importers/v1/__init__.py b/superset/commands/chart/importers/v1/__init__.py index e5528e0df5f6..86f5f323f681 100644 --- a/superset/commands/chart/importers/v1/__init__.py +++ b/superset/commands/chart/importers/v1/__init__.py @@ -59,7 +59,8 @@ class ImportChartsCommand(ImportModelsCommand): "annotation_layers/": ImportV1AnnotationLayerSchema(), "charts/": ImportV1ChartSchema(), "datasets/": ImportV1DatasetSchema(), - "databases/": ImportV1DatabaseSchema(), + # Related databases are reused rather than overwritten. + "databases/": ImportV1DatabaseSchema(overwrites_existing=False), } import_error = ChartImportError diff --git a/superset/commands/dashboard/importers/v1/__init__.py b/superset/commands/dashboard/importers/v1/__init__.py index dbad2998e588..52197ce7e8eb 100644 --- a/superset/commands/dashboard/importers/v1/__init__.py +++ b/superset/commands/dashboard/importers/v1/__init__.py @@ -79,7 +79,9 @@ class ImportDashboardsCommand(ImportModelsCommand): "charts/": ImportV1ChartSchema(), "dashboards/": ImportV1DashboardSchema(), "datasets/": ImportV1DatasetSchema(), - "databases/": ImportV1DatabaseSchema(), + # Related databases are reused rather than overwritten, unless + # `overwrite_all` is set (see `__init__`). + "databases/": ImportV1DatabaseSchema(overwrites_existing=False), "themes/": ImportV1ThemeSchema(), } import_error = DashboardImportError @@ -87,6 +89,14 @@ class ImportDashboardsCommand(ImportModelsCommand): def __init__(self, contents: dict[str, str], *args: Any, **kwargs: Any) -> None: self.overwrite_all = kwargs.pop("overwrite_all", False) super().__init__(contents, *args, **kwargs) + # With both flags set, `_import` overwrites related databases, so their + # schema must ask for any secret the stored connection cannot reveal. + self.schemas = { + **self.schemas, + "databases/": ImportV1DatabaseSchema( + overwrites_existing=self.overwrite and self.overwrite_all + ), + } # not sure if overriding run is the best approach here # it works fine and is better than a global variable imo @@ -181,7 +191,10 @@ def _import( database_ids: dict[str, int] = {} for file_name, config in configs.items(): if file_name.startswith("databases/") and config["uuid"] in database_uuids: - database = import_database(config, overwrite=overwrite_assets) + # Dashboard bundles carry only some Secure Extra blocks. + database = import_database( + config, overwrite=overwrite_assets, partial_secure_extra=True + ) database_ids[str(database.uuid)] = database.id # import datasets with the correct parent ref diff --git a/superset/commands/database/create.py b/superset/commands/database/create.py index 5e85fb0af548..b1e9d011e119 100644 --- a/superset/commands/database/create.py +++ b/superset/commands/database/create.py @@ -29,6 +29,7 @@ DatabaseExistsValidationError, DatabaseInvalidError, DatabaseRequiredFieldValidationError, + MissingOAuth2TokenError, ) from superset.commands.database.ssh_tunnel.exceptions import ( SSHTunnelCreateFailedError, @@ -94,7 +95,16 @@ def run(self) -> Model: try: # create database and associated schema/catalog permissions database = self._create_database() - add_permissions(database) + try: + add_permissions(database) + except (OAuth2RedirectError, MissingOAuth2TokenError): + # The connection test ran on the connection's own settings, but + # listing catalogs and schemas runs through impersonation as the + # creating admin, who has no OAuth2 token yet. The connection is + # valid; as on update, permissions sync when access is first set + # up. Failing here reported a 422 while starting the OAuth2 flow + # had already committed the row (sc-126623). + pass except ( SSHTunnelInvalidError, SSHTunnelCreateFailedError, diff --git a/superset/commands/database/importers/v1/utils.py b/superset/commands/database/importers/v1/utils.py index b8637de3da72..1fb2f751ed43 100644 --- a/superset/commands/database/importers/v1/utils.py +++ b/superset/commands/database/importers/v1/utils.py @@ -20,18 +20,22 @@ from flask import current_app as app -from superset import db, security_manager +from superset import db, is_feature_enabled, security_manager from superset.commands.database.exceptions import DatabaseInvalidError from superset.commands.database.utils import ( add_permissions, - engine_params_changed, + import_connection_identity_changed, ssh_tunnel_rebind_unsafe, - uri_identity_changed, ) -from superset.commands.exceptions import ImportFailedError -from superset.constants import PASSWORD_MASK +from superset.commands.exceptions import ImportFailedError, ImportRefusedError +from superset.constants import EMBEDDED_CREDENTIALS_KEY, PASSWORD_MASK from superset.databases.ssh_tunnel.models import SSHTunnel -from superset.databases.utils import make_url_safe +from superset.databases.utils import ( + embedded_credentials_incomplete, + embedded_credentials_missing_fields, + make_url_safe, +) +from superset.db_engine_specs import BaseEngineSpec from superset.db_engine_specs.exceptions import SupersetDBAPIError from superset.exceptions import ( OAuth2RedirectError, @@ -44,23 +48,6 @@ logger = logging.getLogger(__name__) -def _connection_identity_changed(existing: Database, config: dict[str, Any]) -> bool: - """Whether the import points the database at a different endpoint.""" - if uri_identity_changed(existing.sqlalchemy_uri, config.get("sqlalchemy_uri")): - return True - - # The URI's host/port aren't the whole story: `extra.engine_params` - # (e.g. `connect_args.host`/`port`) is merged into the actual DBAPI - # connect kwargs and can override them. An import that opens a live - # connection (`add_permissions` -> `get_all_catalog_names`) with a - # rehydrated stored password must not do so against a destination this - # field silently redirected. - submitted_extra = config.get("extra") - if isinstance(submitted_extra, dict): - submitted_extra = json.dumps(submitted_extra) - return engine_params_changed(existing.extra, submitted_extra) - - def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> None: """ Refuse an overwrite that changes the connection endpoint without fresh @@ -71,7 +58,7 @@ def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> N host while the stored password (or SSH tunnel key) is silently kept: the next connection would hand the real credential to the new endpoint. """ - if _connection_identity_changed(existing, config): + if import_connection_identity_changed(existing, config): try: uri_password = make_url_safe(config["sqlalchemy_uri"]).password except DatabaseInvalidError: @@ -96,6 +83,48 @@ def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> N ) +def _refuse_incomplete_embedded_credentials( + database_name: str, + encrypted_extra: dict[str, Any], + engine_spec: type[BaseEngineSpec], +) -> None: + """ + Refuse a half-filled embedded guest credential, as the dialog and API do. + + Only the secrets that arrive masked are revealed from the stored credential, + so one the file leaves out entirely (e.g. a key-pair credential without its + private key) would replace the stored credential with one no embedded + viewer can log in with. + """ + if not ( + is_feature_enabled("EMBEDDED_CREDENTIAL_FALLBACK") + and is_feature_enabled("EMBEDDED_SUPERSET") + ): + return + + credentials = encrypted_extra.get(EMBEDDED_CREDENTIALS_KEY) + if not embedded_credentials_incomplete( + credentials, + engine_spec.validate_embedded_credentials, + accept_json_string=False, + ): + return + + sensitive_fields = engine_spec.encrypted_extra_sensitive_fields + labels = sensitive_fields if isinstance(sensitive_fields, dict) else {} + missing = [ + field + (f" ({labels[field]})" if field in labels else "") + for field in embedded_credentials_missing_fields(credentials) + ] + raise ImportRefusedError( + f"Embedded guest credentials of database '{database_name}' are invalid " + "or incomplete." + + (f" Missing: {', '.join(missing)}." if missing else "") + + " Provide all required fields or clear them to leave embedded viewers " + "unconfigured." + ) + + def _sync_permissions_best_effort(database: Database) -> None: """ Sync catalog/schema permissions for ``database``, tolerating a transient @@ -115,6 +144,7 @@ def import_database( # noqa: C901 config: dict[str, Any], overwrite: bool = False, ignore_permissions: bool = False, + partial_secure_extra: bool = False, ) -> Database: can_write = ignore_permissions or security_manager.can_access( "can_write", @@ -174,7 +204,7 @@ def import_database( # noqa: C901 if ( existing and existing.encrypted_extra - and not _connection_identity_changed(existing, config) + and not import_connection_identity_changed(existing, config) ): old_config = json.loads(existing.encrypted_extra) new_config = json.loads(masked_encrypted_extra) @@ -186,10 +216,39 @@ def import_database( # noqa: C901 new_config, sensitive_fields, ) + # A database export carries all of Secure Extra, so what it omits is + # removed. Anything else keeps what it does not carry. + if partial_secure_extra: + revealed = {**old_config, **revealed} config["encrypted_extra"] = json.dumps(revealed) else: config["encrypted_extra"] = masked_encrypted_extra + if encrypted_extra := config.get("encrypted_extra"): + # The incoming URI decides the engine: an overwrite can change it, and the + # stored engine's rules would miss the new engine's secrets and fields. + engine_spec = Database.get_db_engine_spec( + make_url_safe(config["sqlalchemy_uri"]) + ) + payload = json.loads(encrypted_extra) + + # Never store a mask as if it were the secret. The schema asks for any value + # that cannot be revealed. A plain `encrypted_extra` skips it, and a connection + # already storing a mask exports that way, since masking it changes nothing. + if still_masked := json.get_masked_fields( + payload, + engine_spec.encrypted_extra_sensitive_field_paths(), + ): + raise ImportFailedError( + "Must provide value for masked_encrypted_extra field: " + + ", ".join(still_masked) + ) + + if isinstance(payload, dict): + _refuse_incomplete_embedded_credentials( + config["database_name"], payload, engine_spec + ) + ssh_tunnel_config = config.pop("ssh_tunnel", None) # set SQLAlchemy URI via `set_sqlalchemy_uri` so that the password gets masked diff --git a/superset/commands/database/utils.py b/superset/commands/database/utils.py index 6cbf43188d82..9488b0baaa57 100644 --- a/superset/commands/database/utils.py +++ b/superset/commands/database/utils.py @@ -117,6 +117,25 @@ def _engine_params(serialized_extra: str | None) -> dict[str, Any]: return _engine_params(submitted_extra) != _engine_params(existing_extra) +def import_connection_identity_changed( + existing: Database, config: dict[str, Any] +) -> bool: + """Whether an import config points ``existing`` at a different endpoint.""" + if uri_identity_changed(existing.sqlalchemy_uri, config.get("sqlalchemy_uri")): + return True + + # The URI's host/port aren't the whole story: `extra.engine_params` + # (e.g. `connect_args.host`/`port`) is merged into the actual DBAPI + # connect kwargs and can override them. An import that opens a live + # connection (`add_permissions` -> `get_all_catalog_names`) with a + # rehydrated stored password must not do so against a destination this + # field silently redirected. + submitted_extra = config.get("extra") + if isinstance(submitted_extra, dict): + submitted_extra = json.dumps(submitted_extra) + return engine_params_changed(existing.extra, submitted_extra) + + def ssh_tunnel_endpoint_changed( existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None ) -> bool: diff --git a/superset/commands/database/validate.py b/superset/commands/database/validate.py index 0a4081e96729..1bde46108af8 100644 --- a/superset/commands/database/validate.py +++ b/superset/commands/database/validate.py @@ -33,9 +33,9 @@ ssh_tunnel_endpoint_changed, uri_identity_changed, ) -from superset.constants import PASSWORD_MASK +from superset.constants import EMBEDDED_CREDENTIALS_KEY, PASSWORD_MASK from superset.daos.database import DatabaseDAO -from superset.databases.utils import make_url_safe +from superset.databases.utils import embedded_credentials_incomplete, make_url_safe from superset.db_engine_specs import get_engine_spec from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.extensions import event_logger @@ -65,6 +65,8 @@ def run(self) -> None: # noqa: C901 if database_name_error := self._get_database_name_error(): errors.append(database_name_error) errors.extend(self._get_ssh_tunnel_errors()) + if embedded_credentials_error := self._get_embedded_credentials_error(): + errors.append(embedded_credentials_error) if errors: event_logger.log_with_context(action="validation_error", engine=engine) raise InvalidParametersError(errors) @@ -98,6 +100,9 @@ def run(self) -> None: # noqa: C901 # Collect SSH tunnel errors errors.extend(self._get_ssh_tunnel_errors()) + if embedded_credentials_error := self._get_embedded_credentials_error(): + errors.append(embedded_credentials_error) + if errors: event_logger.log_with_context(action="validation_error", engine=engine) raise InvalidParametersError(errors) @@ -285,6 +290,59 @@ def _get_database_name_error(self) -> Optional[SupersetError]: ) return None + def _get_embedded_credentials_error(self) -> Optional[SupersetError]: + """Reject an embedded fallback credential that is missing one of its halves. + + `Database.get_embedded_fallback_credentials` ignores such a credential, so + saving one leaves a setting that reads as configured and never applies. + + The connection dialog validates *before* it folds `x-encrypted-extra` + parameters into `masked_encrypted_extra`, so the value arrives under + `parameters` from the dialog and under `masked_encrypted_extra` from a direct + API caller. An explicit form value replaces the stored value, including + when the credential is cleared. Disabled fields must not block edits. + """ + if not ( + is_feature_enabled("EMBEDDED_CREDENTIAL_FALLBACK") + and is_feature_enabled("EMBEDDED_SUPERSET") + ): + return None + + parameters = self._properties.get("parameters") or {} + if EMBEDDED_CREDENTIALS_KEY in parameters: + credentials = parameters[EMBEDDED_CREDENTIALS_KEY] + else: + try: + encrypted_extra = json.loads( + self._properties.get("masked_encrypted_extra") or "{}" + ) + except json.JSONDecodeError: + encrypted_extra = {} + credentials = ( + encrypted_extra.get(EMBEDDED_CREDENTIALS_KEY) + if isinstance(encrypted_extra, dict) + else None + ) + + engine_spec = get_engine_spec( + self._properties["engine"], self._properties.get("driver") + ) + if not embedded_credentials_incomplete( + credentials, engine_spec.validate_embedded_credentials + ): + return None + + return SupersetError( + message=__( + "Embedded guest credentials are invalid or incomplete. Provide " + "all required fields or clear them to leave embedded viewers " + "unconfigured." + ), + error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR, + level=ErrorLevel.ERROR, + extra={"invalid": [EMBEDDED_CREDENTIALS_KEY]}, + ) + def validate(self) -> None: """Load the model in preparation for run().""" self._load_model() diff --git a/superset/commands/dataset/export.py b/superset/commands/dataset/export.py index 26448f194d59..ff0aec227a62 100644 --- a/superset/commands/dataset/export.py +++ b/superset/commands/dataset/export.py @@ -23,6 +23,7 @@ import yaml from superset.commands.export.models import ExportModelsCommand +from superset.constants import EMBEDDED_CREDENTIALS_KEY from superset.connectors.sqla.models import SqlaTable from superset.commands.dataset.exceptions import DatasetNotFoundError from superset.daos.dataset import DatasetDAO @@ -36,6 +37,41 @@ JSON_KEYS = {"params", "template_params", "extra"} +# Secure Extra blocks a dashboard, chart or dataset bundle carries, so a promoted +# dashboard keeps its OAuth2 client and embedded viewer credential. Every engine +# masks their secrets (`BaseEngineSpec.encrypted_extra_sensitive_field_paths`). +_CARRIED_SECURE_EXTRA_KEYS = ("oauth2_client_info", EMBEDDED_CREDENTIALS_KEY) + + +def _carried_secure_extra(masked_encrypted_extra: str | None) -> str | None: + """ + Return the masked Secure Extra blocks a bundle carries, or ``None``. + + Only these Superset-owned blocks, never the rest of Secure Extra: the bundle + is open to anyone who can export a dashboard or dataset, and an engine's own + settings -- client certificates, service-account keys -- are not all + registered as sensitive, so masking cannot be relied on to hide them. + """ + if not masked_encrypted_extra: + return None + try: + secure_extra = json.loads(masked_encrypted_extra) + except json.JSONDecodeError: + return None + if not isinstance(secure_extra, dict): + return None + # An engine masking with ``$.*`` (Databricks, the base spec) replaces a whole + # block with a mask string. Carrying it would have the import ask for the + # block and store the answer as a string no query can use as an OAuth2 + # client, so only blocks masked field by field travel. + carried = { + key: secure_extra[key] + for key in _CARRIED_SECURE_EXTRA_KEYS + if isinstance(secure_extra.get(key), dict) and secure_extra[key] + } + return json.dumps(carried) if carried else None + + class ExportDatasetsCommand(ExportModelsCommand): dao = DatasetDAO not_found = DatasetNotFoundError @@ -137,6 +173,11 @@ def _export( ) payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload) + if carried := _carried_secure_extra( + model.database.masked_encrypted_extra + ): + payload["masked_encrypted_extra"] = carried + payload["version"] = EXPORT_VERSION yield ( diff --git a/superset/commands/dataset/importers/v1/__init__.py b/superset/commands/dataset/importers/v1/__init__.py index 149232cf1eec..b4554663db83 100644 --- a/superset/commands/dataset/importers/v1/__init__.py +++ b/superset/commands/dataset/importers/v1/__init__.py @@ -40,7 +40,8 @@ class ImportDatasetsCommand(ImportModelsCommand): model_name = "dataset" prefix = "datasets/" schemas: dict[str, Schema] = { - "databases/": ImportV1DatabaseSchema(), + # Related databases are reused rather than overwritten. + "databases/": ImportV1DatabaseSchema(overwrites_existing=False), "datasets/": ImportV1DatasetSchema(), } import_error = DatasetImportError diff --git a/superset/commands/exceptions.py b/superset/commands/exceptions.py index 2e962acf635e..e14d50ddda50 100644 --- a/superset/commands/exceptions.py +++ b/superset/commands/exceptions.py @@ -105,6 +105,12 @@ class ImportFailedError(CommandException): message = "Import failed for an unknown reason" +class ImportRefusedError(ImportFailedError): + """An import refused because of what the bundle contains, not a server fault.""" + + status = 400 + + class DatasourceTypeInvalidError(ValidationError): status = 422 diff --git a/superset/commands/importers/v1/assets.py b/superset/commands/importers/v1/assets.py index 128236c23e07..14b786aeca4f 100644 --- a/superset/commands/importers/v1/assets.py +++ b/superset/commands/importers/v1/assets.py @@ -66,8 +66,14 @@ def _on_import_error(ex: Exception) -> None: - """Keep dependency validation actionable after the transaction rolls back.""" - if isinstance(ex, SemanticReferenceError): + """ + Keep dependency validation and deliberate import refusals actionable after + the transaction rolls back. + + An ``ImportFailedError`` names what the bundle must change, e.g. a secret it + must provide; re-raising it bare would leave only "unknown reason". + """ + if isinstance(ex, (SemanticReferenceError, ImportFailedError)): raise ex on_error(ex, catches=(Exception,), reraise=ImportFailedError) diff --git a/superset/commands/query/importers/v1/__init__.py b/superset/commands/query/importers/v1/__init__.py index 1f7290d6f7c6..96e56d201ca2 100644 --- a/superset/commands/query/importers/v1/__init__.py +++ b/superset/commands/query/importers/v1/__init__.py @@ -37,7 +37,8 @@ class ImportSavedQueriesCommand(ImportModelsCommand): model_name = "saved_queries" prefix = "queries/" schemas: dict[str, Schema] = { - "databases/": ImportV1DatabaseSchema(), + # Related databases are reused rather than overwritten. + "databases/": ImportV1DatabaseSchema(overwrites_existing=False), "queries/": ImportV1SavedQuerySchema(), } import_error = SavedQueryImportError diff --git a/superset/config.py b/superset/config.py index a4af2f8b50c0..b9b1ec81ab2d 100644 --- a/superset/config.py +++ b/superset/config.py @@ -988,6 +988,12 @@ class D3TimeFormat(TypedDict, total=False): # @lifecycle: stable # @category: runtime_config "EMBEDDABLE_CHARTS": True, + # Allow a database connection to carry a username with a password or key pair used + # only for embedded guest requests, while logged-in users continue to authenticate + # per-user via OAuth2. Requires EMBEDDED_SUPERSET. + # @lifecycle: testing + # @category: security + "EMBEDDED_CREDENTIAL_FALLBACK": False, # Enable embedded Superset functionality # @lifecycle: stable # @category: runtime_config diff --git a/superset/constants.py b/superset/constants.py index 818683352df5..98a5785c9cd7 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -39,6 +39,13 @@ PASSWORD_MASK = "X" * 10 +# Key under which a connection's embedded-guest fallback credential is stored in +# ``encrypted_extra``, and the name of the matching connection-dialog parameter. +# Shared rather than per-engine: the frontend's ``FORM_FIELD_MAP`` and +# ``FormFieldOrder`` key off the same name, so an engine that renamed it would +# store a credential the dialog could never render or edit. +EMBEDDED_CREDENTIALS_KEY = "embedded_credentials" + # ``python_date_format`` values that mark an integer column as a Unix epoch, # mapped to the number of units per second. The pandas resolution is the # suffix after ``epoch_`` (``s``/``ms``/``us``). diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index d036d94c0138..89af2a0e028c 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -3234,6 +3234,13 @@ def import_(self) -> Response: the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key_password"}`. type: string + encrypted_extra_secrets: + description: >- + JSON map of sensitive values for masked_encrypted_extra fields + of featured databases. Keys are file paths (e.g., + "databases/db.yaml") and values are JSON objects mapping + JSONPath expressions to secrets. + type: string responses: 200: description: Dashboard import result @@ -3275,6 +3282,7 @@ def import_(self) -> Response: ssh_tunnel_priv_key_passwords = parse_json_form_field( "ssh_tunnel_private_key_passwords" ) + encrypted_extra_secrets = parse_json_form_field("encrypted_extra_secrets") command = ImportDashboardsCommand( contents, @@ -3284,6 +3292,7 @@ def import_(self) -> Response: ssh_tunnel_passwords=ssh_tunnel_passwords, ssh_tunnel_private_keys=ssh_tunnel_private_keys, ssh_tunnel_priv_key_passwords=ssh_tunnel_priv_key_passwords, + encrypted_extra_secrets=encrypted_extra_secrets, ) command.run() return self.response(200, message="OK") diff --git a/superset/databases/api.py b/superset/databases/api.py index 129571de0303..8ea58fc9b3b8 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -584,7 +584,7 @@ def put(self, pk: int) -> Response: $ref: '#/components/responses/500' """ try: - item = self.edit_model_schema.load(request.json) + item = DatabasePutSchema(database_id=pk).load(request.json) # This validates custom Schema with custom validations except ValidationError as error: return self.response_400(message=error.messages) diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 28045e32c379..73fc87e0aae1 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -46,14 +46,16 @@ SSHTunnelMissingCredentials, ) from superset.commands.database.uploaders.base import UploadFileType -from superset.constants import PASSWORD_MASK +from superset.commands.database.utils import import_connection_identity_changed +from superset.constants import EMBEDDED_CREDENTIALS_KEY, PASSWORD_MASK from superset.databases.types import ( # pylint:disable=unused-import + encrypted_field_properties, # noqa: F401 EncryptedDict, # noqa: F401 - EncryptedField, EncryptedString, # noqa: F401 ) -from superset.databases.utils import make_url_safe +from superset.databases.utils import embedded_credentials_incomplete, make_url_safe from superset.db_engine_specs import get_engine_spec +from superset.db_engine_specs.base import BaseEngineSpec from superset.exceptions import CertificateException, SupersetSecurityException from superset.models.core import ConfigurationMethod, Database from superset.security.analytics_db_safety import check_sqlalchemy_uri @@ -365,6 +367,10 @@ class DatabaseParametersSchemaMixin: # pylint: disable=too-few-public-methods When using this mixin make sure that `sqlalchemy_uri` is not required. """ + database_id: int | None = None + # A saved mask would become the credential, so saving needs a secret behind it. + masks_need_stored_secret = True + engine = fields.String( allow_none=True, metadata={"description": "SQLAlchemy engine to use"} ) @@ -450,6 +456,98 @@ def build_sqlalchemy_uri( return data + @validates_schema + def validate_embedded_credentials( + self, data: dict[str, Any], **kwargs: Any + ) -> None: + """Validate authored credentials with the effective connection's engine.""" + value = data.get("masked_encrypted_extra") + if not value or not ( + is_feature_enabled("EMBEDDED_CREDENTIAL_FALLBACK") + and is_feature_enabled("EMBEDDED_SUPERSET") + ): + return + + encrypted_extra = json.loads(value) + if EMBEDDED_CREDENTIALS_KEY not in encrypted_extra: + return + + engine_spec = BaseEngineSpec + stored = ( + db.session.get(Database, self.database_id) + if self.database_id is not None + else None + ) + if sqlalchemy_uri := data.get("sqlalchemy_uri"): + engine_spec = Database(sqlalchemy_uri=sqlalchemy_uri).db_engine_spec + elif stored: + engine_spec = stored.db_engine_spec + + credentials = encrypted_extra[EMBEDDED_CREDENTIALS_KEY] + if embedded_credentials_incomplete( + credentials, + engine_spec.validate_embedded_credentials, + accept_json_string=False, + ): + raise ValidationError( + _( + "Embedded guest credentials are invalid or incomplete. Provide " + "all required fields or clear them to leave embedded viewers " + "unconfigured." + ), + field_name="masked_encrypted_extra", + ) + + # A mask only stands in for a secret the connection has stored. Anywhere + # else `unmask_encrypted_extra` keeps it and saves it as the credential. + if self.masks_need_stored_secret and any( + field.startswith(f"$.{EMBEDDED_CREDENTIALS_KEY}.") + for field in json.unrevealable_masked_fields( + json.loads(stored.encrypted_extra) + if stored and stored.encrypted_extra + else None, + encrypted_extra, + engine_spec.encrypted_extra_sensitive_field_paths(), + ) + ): + raise ValidationError( + _( + "Embedded guest credentials are invalid or incomplete. Provide " + "all required fields or clear them to leave embedded viewers " + "unconfigured." + ), + field_name="masked_encrypted_extra", + ) + + # A credential only applies to a per-user OAuth2 connection with + # impersonation on (`Database.embedded_fallback_applies`). Anywhere else + # it would save and silently do nothing -- or, with impersonation off, + # leave every viewer on the connection's own account while the setting + # reads as configured -- so refuse it rather than store it. + if ( + engine_spec.supports_embedded_credential_fallback + and engine_spec.validate_embedded_credentials(credentials) is not None + ): + impersonate_user = data.get( + "impersonate_user", stored.impersonate_user if stored else False + ) + client_info = encrypted_extra.get("oauth2_client_info") + has_oauth2_client = bool(isinstance(client_info, dict) and client_info) or ( + engine_spec.supports_oauth2 + and engine_spec.engine_name + in current_app.config["DATABASE_OAUTH2_CLIENTS"] + ) + if not (impersonate_user and has_oauth2_client): + raise ValidationError( + _( + "Embedded guest credentials only apply to connections that " + 'use OAuth2 with "Impersonate logged in user" turned on. ' + "Configure OAuth2 client information and turn on " + "impersonation, or clear the embedded credentials." + ), + field_name="masked_encrypted_extra", + ) + def rename_encrypted_extra( self: Schema, @@ -507,6 +605,7 @@ class Meta: # pylint: disable=too-few-public-methods ) masked_encrypted_extra = fields.String( metadata={"description": encrypted_extra_description}, + # The command validates the effective credential after applying form edits. validate=encrypted_extra_validator, allow_none=True, ) @@ -672,6 +771,11 @@ class Meta: # pylint: disable=too-few-public-methods class DatabasePutSchema(DatabaseParametersSchemaMixin, Schema): + def __init__(self, *, database_id: int | None = None, **kwargs: Any) -> None: + """Keep the existing connection identity for updates without a URI.""" + super().__init__(**kwargs) + self.database_id = database_id + class Meta: # pylint: disable=too-few-public-methods unknown = EXCLUDE @@ -731,6 +835,10 @@ class Meta: # pylint: disable=too-few-public-methods class DatabaseTestConnectionSchema(DatabaseParametersSchemaMixin, Schema): + # Saves nothing, and `TestConnectionDatabaseCommand` finds the connection by name + # to unmask, which this schema has no `database_id` to do. + masks_need_stored_secret = False + rename_encrypted_extra = pre_load(rename_encrypted_extra) database_name = fields.String( @@ -1039,6 +1147,17 @@ def fix_allow_csv_upload( return data + def __init__(self, *args: Any, overwrites_existing: bool = True, **kwargs: Any): + """ + :param overwrites_existing: whether the import can overwrite a database + that already exists. Dashboard, chart, dataset and query imports + reuse it -- its masked secrets are never applied, so there is nothing + to ask for (an overwrite that still needs one is refused by + ``import_database``). + """ + super().__init__(*args, **kwargs) + self.overwrites_existing = overwrites_existing + database_name = fields.String(required=True) sqlalchemy_uri = fields.String(required=True) password = fields.String(allow_none=True) @@ -1155,26 +1274,40 @@ def validate_masked_encrypted_extra( "File contains both `encrypted_extra` and `masked_encrypted_extra`" ) - if db.session.query(Database).filter_by(uuid=data["uuid"]).first(): - # Existing DB: sensitive values will be revealed from existing - # encrypted_extra in import_database() - return - masked_encrypted_extra = json.loads(data["masked_encrypted_extra"]) - # Determine engine spec from sqlalchemy_uri to get sensitive fields - sqlalchemy_uri = data["sqlalchemy_uri"] - url = make_url_safe(sqlalchemy_uri) - backend = url.get_backend_name() - driver = url.get_driver_name() - db_engine_spec = get_engine_spec(backend, driver=driver) - - # Check if any sensitive field is still masked - masked_fields = get_masked_fields( - masked_encrypted_extra, - db_engine_spec.encrypted_extra_sensitive_field_paths(), + existing = db.session.query(Database).filter_by(uuid=data["uuid"]).first() + if existing and not self.overwrites_existing: + return + + # The incoming URI decides the engine, and so which fields are secret: an + # overwrite can change it, and the stored engine's paths would miss them. + db_engine_spec = Database.get_db_engine_spec( + make_url_safe(data["sqlalchemy_uri"]) ) + if existing: + # Existing DB: masked values are revealed from its stored + # encrypted_extra in import_database(). Any the stored payload has no + # value for -- e.g. a connection promoted without its OAuth2 client or + # embedded credential -- would otherwise be stored as the mask itself, + # so ask for those like a new DB. Nothing is revealed onto a + # connection the import moves, so then ask for every masked value. + masked_fields = json.unrevealable_masked_fields( + json.loads(existing.encrypted_extra) + if existing.encrypted_extra + and not import_connection_identity_changed(existing, data) + else None, + masked_encrypted_extra, + db_engine_spec.encrypted_extra_sensitive_field_paths(), + ) + else: + # Check if any sensitive field is still masked + masked_fields = get_masked_fields( + masked_encrypted_extra, + db_engine_spec.encrypted_extra_sensitive_field_paths(), + ) + if masked_fields: encrypted_extra_fields = db_engine_spec.encrypted_extra_sensitive_fields labels = ( @@ -1193,14 +1326,6 @@ def validate_masked_encrypted_extra( ) -def encrypted_field_properties(self, field: Any, **_) -> dict[str, Any]: # type: ignore - ret = {} - if isinstance(field, EncryptedField): - if self.openapi_version.major > 2: - ret["x-encrypted-extra"] = True - return ret - - class DatabaseSchemaAccessForFileUploadResponse(Schema): schemas = fields.List( fields.String(), diff --git a/superset/databases/types.py b/superset/databases/types.py index 4ab4428604d9..a0cb14321ab0 100644 --- a/superset/databases/types.py +++ b/superset/databases/types.py @@ -17,6 +17,8 @@ # Field has been moved outside of the schemas.py file to # allow for it to be imported from outside of app_context +from typing import Any + from marshmallow import fields @@ -32,3 +34,24 @@ class EncryptedString(EncryptedField, fields.String): class EncryptedDict(EncryptedField, fields.Dict): pass + + +def encrypted_field_properties(self, field: Any, **_) -> dict[str, Any]: # type: ignore + """ + Tag an ``EncryptedField`` parameter with ``x-encrypted-extra``. + + Registered on a ``MarshmallowPlugin``'s converter when building a DB engine + spec's ``parameters_json_schema``. The tag is the signal the connection + dialog uses both to render the field and to move its value into + ``masked_encrypted_extra`` rather than posting it as a plain parameter. + + Lives here rather than in ``schemas.py`` so that + ``superset.db_engine_specs.base`` can import it: ``schemas.py`` imports + ``get_engine_spec`` from the ``db_engine_specs`` package, which would make + that a circular import. + """ + ret = {} + if isinstance(field, EncryptedField): + if self.openapi_version.major > 2: + ret["x-encrypted-extra"] = True + return ret diff --git a/superset/databases/utils.py b/superset/databases/utils.py index 645248bede60..4f0ecd9c377f 100644 --- a/superset/databases/utils.py +++ b/superset/databases/utils.py @@ -17,13 +17,16 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any, TYPE_CHECKING from sqlalchemy.engine.url import make_url, URL from sqlalchemy.exc import NoSuchTableError from superset.commands.database.exceptions import DatabaseInvalidError +from superset.constants import EMBEDDED_CREDENTIALS_KEY from superset.sql.parse import Table +from superset.utils import json if TYPE_CHECKING: from superset.databases.schemas import ( @@ -160,3 +163,121 @@ def make_url_safe(raw_url: str | URL) -> URL: else: return raw_url + + +def _is_present(value: Any) -> bool: + """Is a credential field actually filled in? Whitespace does not count.""" + if isinstance(value, str): + return bool(value.strip()) + return bool(value) + + +def validated_embedded_credentials(credentials: Any) -> dict[str, str] | None: + """ + Return a complete embedded fallback credential, or ``None``. + + The canonical shape is ``{username, password}`` or ``{username, auth_method: + "keypair", privatekey_body, privatekey_pass}``; a half-filled credential is + ignored rather than half-applied. Engines may override this default through + ``BaseEngineSpec.validate_embedded_credentials`` for both query-time and + authoring-time validation. + """ + if not isinstance(credentials, dict): + return None + + username = credentials.get("username") + if not isinstance(username, str) or not _is_present(username): + return None + + auth_method = credentials.get("auth_method", "password") + if auth_method == "keypair": + privatekey_body = credentials.get("privatekey_body") + privatekey_pass = credentials.get("privatekey_pass", "") + if ( + not isinstance(privatekey_body, str) + or not _is_present(privatekey_body) + or not isinstance(privatekey_pass, str) + ): + return None + return { + "username": username, + "auth_method": "keypair", + "privatekey_body": privatekey_body, + "privatekey_pass": privatekey_pass, + } + + password = credentials.get("password") + if auth_method != "password" or not isinstance(password, str) or not password: + return None + return {"username": username, "password": password} + + +def embedded_credentials_incomplete( + value: Any, + validate_credentials: Callable[[Any], dict[str, str] | None], + *, + accept_json_string: bool = True, +) -> bool: + """ + Is an embedded credential malformed or incomplete for its engine? + + Absent and entirely blank values pass -- they mean "no credential configured". + Accepts the value as a dict or as the JSON string the connection dialog posts. + Saving passes ``accept_json_string=False``: the value is stored as sent, and a + string is refused at query time however complete the JSON inside it. + Use the engine's query-time validator when the connection is known, keeping + authoring checks consistent with engine-specific credential shapes. + """ + if value is None or value == "": + return False + + is_string = isinstance(value, str) + if is_string: + try: + value = json.loads(value) + except json.JSONDecodeError: + return True + + if value is None: + return False + if not isinstance(value, dict): + return True + + # `auth_method` alone is not a credential -- the dialog writes it as soon as the + # selector renders, so an untouched sub-form must not count as half-filled. + if not any( + _is_present(field) for key, field in value.items() if key != "auth_method" + ): + return False + + if is_string and not accept_json_string: + return True + + return validate_credentials(value) is None + + +def embedded_credentials_missing_fields(credentials: Any) -> list[str]: + """ + Name the fields of the canonical credential shape that ``credentials`` lacks. + + Paths are JSONPath into Secure Extra (``$.embedded_credentials.``), the + form the import already uses to name a secret it needs. Returns an empty list + when nothing canonical is missing, e.g. an unknown ``auth_method`` or a shape + only an engine-specific validator rejects. + """ + if not isinstance(credentials, dict): + return [] + + missing: list[str] = [] + if not _is_present(credentials.get("username")): + missing.append("username") + + auth_method = credentials.get("auth_method", "password") + if auth_method == "keypair": + if not _is_present(credentials.get("privatekey_body")): + missing.append("privatekey_body") + elif auth_method == "password": + if not _is_present(credentials.get("password")): + missing.append("password") + + return [f"$.{EMBEDDED_CREDENTIALS_KEY}.{field}" for field in missing] diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 8c4f89c044fa..179a5a9a6585 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -1824,6 +1824,13 @@ def import_(self) -> Response: the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key_password"}`. type: string + encrypted_extra_secrets: + description: >- + JSON map of sensitive values for masked_encrypted_extra fields + of featured databases. Keys are file paths (e.g., + "databases/db.yaml") and values are JSON objects mapping + JSONPath expressions to secrets. + type: string responses: 200: description: Dataset import result @@ -1865,6 +1872,7 @@ def import_(self) -> Response: ssh_tunnel_priv_key_passwords = parse_json_form_field( "ssh_tunnel_private_key_passwords" ) + encrypted_extra_secrets = parse_json_form_field("encrypted_extra_secrets") command = ImportDatasetsCommand( contents, @@ -1875,6 +1883,7 @@ def import_(self) -> Response: ssh_tunnel_passwords=ssh_tunnel_passwords, ssh_tunnel_private_keys=ssh_tunnel_private_keys, ssh_tunnel_priv_key_passwords=ssh_tunnel_priv_key_passwords, + encrypted_extra_secrets=encrypted_extra_secrets, ) command.run() return self.response(200, message="OK") diff --git a/superset/datasource/api.py b/superset/datasource/api.py index a207204f0522..cdea18ad6ca4 100644 --- a/superset/datasource/api.py +++ b/superset/datasource/api.py @@ -58,6 +58,7 @@ from superset.semantic_layers.models import SemanticView from superset.superset_typing import FlaskResponse from superset.utils import json +from superset.utils.cache_keys import add_impersonation_cache_key_if_needed from superset.utils.core import ( apply_max_row_limit, DatasourceType, @@ -115,6 +116,10 @@ def _column_values_cache_key( inner_rls := datasource.get_inner_rls_cache_key() ): material["inner_rls"] = inner_rls + # The query runs under the caller's connection identity, which an embedded + # credential or per-user caching makes part of the result. + if isinstance(datasource, (SqlaTable, Query)): + add_impersonation_cache_key_if_needed(datasource.database, material) return "col_values:" + hash_from_dict(material) diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index bb64356c1491..11b09b926d0a 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -63,13 +63,19 @@ ) from sqlalchemy.types import TypeEngine -from superset import db +from superset import db, is_feature_enabled from superset.constants import ( + EMBEDDED_CREDENTIALS_KEY, EPOCH_FORMATS, QUERY_CANCEL_KEY, TimeGrain as TimeGrainConstants, ) -from superset.databases.utils import get_table_metadata, make_url_safe +from superset.databases.types import encrypted_field_properties +from superset.databases.utils import ( + get_table_metadata, + make_url_safe, + validated_embedded_credentials, +) from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import ( OAuth2Error, @@ -786,6 +792,12 @@ def get_extended_aggregation_func( # Does the engine supports OAuth 2.0? This requires logic to be added to one of the # the user impersonation methods to handle personal tokens. supports_oauth2 = False + + # Can a connection on this engine carry a username/password used only for embedded + # guest requests? Opting in requires the engine's impersonation to be a no-op for + # the fallback path, so that the stored credential -- rather than a per-user OAuth2 + # token the guest does not have -- authenticates the connection. + supports_embedded_credential_fallback = False oauth2_scope = "" oauth2_authorization_request_uri: str | None = None # pylint: disable=invalid-name oauth2_token_request_uri: str | None = None @@ -861,14 +873,16 @@ def encrypted_extra_sensitive_field_paths(cls) -> set[str]: Returns a set of JSONPath expressions for fields that should be masked in the ``masked_encrypted_extra`` JSON. - The OAuth2 client secret is always included, since - ``Database.get_oauth2_config`` reads ``oauth2_client_info`` from the - ``encrypted_extra`` of any database regardless of its engine, so engine - specs that override ``encrypted_extra_sensitive_fields`` cannot - accidentally expose it. + The OAuth2 client secret and the embedded fallback credential's secrets are + always included: ``Database`` reads both blocks from the ``encrypted_extra`` + of any database regardless of its engine, so engine specs that override + ``encrypted_extra_sensitive_fields`` cannot accidentally expose them. """ return set(cls.encrypted_extra_sensitive_fields) | { - "$.oauth2_client_info.secret" + "$.oauth2_client_info.secret", + f"$.{EMBEDDED_CREDENTIALS_KEY}.password", + f"$.{EMBEDDED_CREDENTIALS_KEY}.privatekey_body", + f"$.{EMBEDDED_CREDENTIALS_KEY}.privatekey_pass", } @classmethod @@ -913,6 +927,18 @@ def start_oauth2_dance(cls, database: Database) -> None: # Prevent circular import. from superset.daos.key_value import KeyValueDAO + # Every caller is expected to have gone through `needs_oauth2`, which refuses + # the dance for principals that cannot complete it. This is the backstop: the + # state below needs a user id, and an embedded `GuestUser` has none, so without + # it a caller that skipped the check gets an `AttributeError` and a 500 rather + # than something a reader can act on. + user_id = getattr(getattr(g, "user", None), "id", None) + if user_id is None: + raise OAuth2Error( + "OAuth2 requires an authenticated user with an id; " + "the current principal has none" + ) + tab_id = str(uuid4()) default_redirect_uri = get_oauth2_redirect_uri() @@ -940,7 +966,7 @@ def start_oauth2_dance(cls, database: Database) -> None: state: OAuth2State = { # Database ID and user ID are the primary key associated with the token. "database_id": database.id, - "user_id": g.user.id, + "user_id": user_id, # In multi-instance deployments there might be a single proxy handling # redirects, with a custom `DATABASE_OAUTH2_REDIRECT_URI`. Since the OAuth2 # application requires every redirect URL to be registered a priori, this @@ -2611,6 +2637,67 @@ def impersonate_user( return url, engine_kwargs + @classmethod + def validate_embedded_credentials(cls, credentials: Any) -> dict[str, str] | None: + """ + Return a complete embedded fallback credential, or ``None``. + + Called by ``Database.get_embedded_fallback_credentials`` once its gate has + decided the request is one the fallback governs. The canonical shape is + ``{username, password}`` or ``{username, auth_method: "keypair", + privatekey_body, privatekey_pass}``; an engine whose driver needs a + different shape overrides this. The database API and parameter-validation + command use the same hook when checking credentials before saving. + """ + return validated_embedded_credentials(credentials) + + @classmethod + def apply_embedded_credentials( + cls, + database: Database, # pylint: disable=unused-argument + credentials: dict[str, str], + url: URL, + engine_kwargs: dict[str, Any], + ) -> tuple[URL, dict[str, Any]]: + """ + Apply an embedded fallback credential to the connection. + + Called *instead of* :meth:`impersonate_user`, not alongside it -- see the + comment in ``Database._get_sqla_engine`` for why skipping impersonation is + load-bearing twice over. + + The identity is replaced on the URL rather than added to ``connect_args`` + because the per-process engine cache is keyed on the rendered URL and the + engine kwargs: putting it on the URL is what keeps a guest engine and a + logged-in user's engine on separate entries. ``URL.create`` rather than + ``URL.set`` so that the base credential is *replaced* -- ``set`` ignores + ``password=None``, which would leave a key-pair credential carrying the + connection's own password. + """ + url = URL.create( + drivername=url.drivername, + username=credentials["username"], + password=credentials.get("password"), + host=url.host, + port=url.port, + database=url.database, + query=url.query, + ) + return url, engine_kwargs + + @classmethod + def get_embedded_credential_cache_key(cls, credentials: dict[str, str]) -> str: + """ + Cache-key material for results produced under an embedded fallback credential. + + Keyed on the configured identity rather than on the requesting principal: a + ``GuestUser`` carries whatever username the guest token claims, and whoever + mints the token must not get to choose which cache entry they read. All + guests on one connection legitimately share an entry -- they do run as one + database user -- and guest-token RLS already separates them. + """ + return f"embedded:{credentials['username']}" + @classmethod @deprecated(deprecated_in="6.0.0") def get_url_for_impersonation( @@ -2690,7 +2777,23 @@ def needs_oauth2(cls, ex: Exception) -> bool: """ Check if the exception is one that indicates OAuth2 is needed. """ - return g and hasattr(g, "user") and isinstance(ex, cls.oauth2_exception) + # The cheap type check comes first so that the common case -- an error that + # has nothing to do with OAuth2 -- never reaches the import or the feature + # flag lookup below. + if not isinstance(ex, cls.oauth2_exception): + return False + + if not (g and hasattr(g, "user")): + return False + + # Prevent circular import. + from superset import security_manager + + # An embedded viewer authenticates with a guest token rather than a Superset + # account, so there is no per-user token to resolve for them and no route by + # which they could authorize one. Starting the dance would raise instead of + # helping; let the driver's own error reach the user. + return not security_manager.is_guest_user(g.user) @classmethod def make_label_compatible(cls, label: str) -> str | quoted_name: @@ -2926,6 +3029,11 @@ def update_params_from_encrypted_extra( # pylint: disable=invalid-name return try: encrypted_extra = json.loads(database.encrypted_extra) + # The embedded fallback credential is read by `Database._get_sqla_engine`, + # not by any driver. Splatting it in here would make it a `create_engine` + # kwarg and raise `TypeError` on every engine that inherits this method, so + # it is dropped regardless of whether the engine opted into the feature. + encrypted_extra.pop(EMBEDDED_CREDENTIALS_KEY, None) params.update(encrypted_extra) except json.JSONDecodeError as ex: logger.error(ex, exc_info=True) @@ -3547,14 +3655,83 @@ def parameters_json_schema(cls) -> Any: """ Return configuration parameters as OpenAPI. """ - if not cls.parameters_schema: - return None + return build_parameters_json_schema(cast("type[BaseEngineSpec]", cls)) - spec = APISpec( - title="Database Parameters", - version="1.0.0", - openapi_version="3.0.2", - plugins=[MarshmallowPlugin()], - ) - spec.components.schema(cls.__name__, schema=cls.parameters_schema) - return spec.to_dict()["components"]["schemas"][cls.__name__] + +def build_parameters_json_schema(engine_spec: type[BaseEngineSpec]) -> Any: + """ + Return a DB engine spec's configuration parameters as OpenAPI. + + A module-level function rather than a method so that it is reachable both from + ``BasicParametersMixin``, which most dynamic-parameter engines inherit, and from + a spec that cannot mix that in because its ``build_sqlalchemy_uri`` takes a + narrower parameters type. Before this existed each such spec carried a + near-verbatim copy, and the copies drifted. + """ + # `parameters_schema` is deliberately absent from `BaseEngineSpec` -- its presence + # is what `ValidateDatabaseParametersCommand` tests to tell a dynamic-parameters + # engine from a URI-only one -- so it is read defensively rather than declared. + parameters_schema = getattr(engine_spec, "parameters_schema", None) + if not parameters_schema: + return None + + ma_plugin = MarshmallowPlugin() + spec = APISpec( + title="Database Parameters", + version="1.0.0", + openapi_version="3.0.2", + plugins=[ma_plugin], + ) + + # `APISpec` has already run `init_spec` for the plugin passed above, so the + # converter exists by now. This is what tags `EncryptedField` parameters with + # `x-encrypted-extra`, which is the signal the connection dialog uses both to + # render the field and to move its value into `masked_encrypted_extra` rather + # than posting it as a plain parameter. + ma_plugin.converter.add_attribute_function(encrypted_field_properties) + + spec.components.schema(engine_spec.__name__, schema=parameters_schema) + schema = spec.to_dict()["components"]["schemas"][engine_spec.__name__] + _apply_embedded_credentials_schema(engine_spec, schema) + return schema + + +def _apply_embedded_credentials_schema( + engine_spec: type[BaseEngineSpec], + schema: dict[str, Any], +) -> None: + """ + Gate and annotate the embedded fallback credential parameter, in place. + + This payload is the sole source of the fields the connection dialog renders, so + dropping the property is all it takes to hide the sub-form -- no feature-flag + plumbing is needed on the frontend. Note that this only stops the credential + being *offered*: one already stored is left untouched, so turning the flag off is + reversible rather than destructive. + + Lives here rather than in each opted-in engine so that a second engine's opt-in + is a schema field and a class attribute, not a copied override. + """ + properties = schema.get("properties") + if not isinstance(properties, dict) or EMBEDDED_CREDENTIALS_KEY not in properties: + return + + # Both flags are required because `Database.embedded_fallback_applies` requires + # both: without `EMBEDDED_SUPERSET` there are no guests for the credential to + # apply to, so offering the field would save a setting the gate then ignores. + if not ( + is_feature_enabled("EMBEDDED_CREDENTIAL_FALLBACK") + and is_feature_enabled("EMBEDDED_SUPERSET") + ): + properties.pop(EMBEDDED_CREDENTIALS_KEY, None) + return + + # Not a default *value* for the field. A property's `default` is the only channel + # the connection dialog plumbs through to a field component (as `default_value`), + # and whether a deployment-wide OAuth2 client exists -- `DATABASE_OAUTH2_CLIENTS`, + # the second half of `Database.is_oauth2_enabled` -- is knowable only here. The + # per-connection half is live form state, so the field reads that itself and shows + # the sub-form when either holds. + properties[EMBEDDED_CREDENTIALS_KEY]["default"] = { + "engine_oauth2_client_configured": engine_spec.is_oauth2_enabled(), + } diff --git a/superset/db_engine_specs/snowflake.py b/superset/db_engine_specs/snowflake.py index dbb8bbb80f1e..9a4151a690a0 100644 --- a/superset/db_engine_specs/snowflake.py +++ b/superset/db_engine_specs/snowflake.py @@ -24,8 +24,6 @@ from urllib import parse import sqlalchemy as sa -from apispec import APISpec -from apispec.ext.marshmallow import MarshmallowPlugin from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from flask import current_app as app, has_request_context @@ -37,13 +35,16 @@ from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError from sqlalchemy.sql import quoted_name from sqlalchemy.sql.elements import ColumnElement +from typing_extensions import NotRequired from superset import is_feature_enabled from superset.constants import TimeGrain +from superset.databases.types import EncryptedString from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import ( BaseEngineSpec, BasicPropertiesType, + build_parameters_json_schema, DatabaseCategory, ) from superset.db_engine_specs.postgres import PostgresBaseEngineSpec @@ -126,6 +127,29 @@ class SnowflakeParametersSchema(Schema): database = fields.Str(required=True) role = fields.Str(required=True) warehouse = fields.Str(required=True) + # Unlike GSheets, the endpoints cannot be defaulted: they live on the customer's + # own Snowflake account (`https://.snowflakecomputing.com/oauth/...`), + # so there is no URI that would be right for every deployment. The scope is + # configured explicitly to match the integration's permitted session role. + oauth2_client_info = EncryptedString( + required=False, + metadata={ + "description": "OAuth2 client information", + }, + allow_none=True, + ) + embedded_credentials = EncryptedString( + required=False, + metadata={ + "description": ( + "Password or PEM key pair used only for embedded guest requests. " + "Every embedded viewer of this connection queries as this one " + "Snowflake user; " + "logged-in users are unaffected and continue to use OAuth2." + ), + }, + allow_none=True, + ) class SnowflakeParametersType(TypedDict): @@ -135,6 +159,26 @@ class SnowflakeParametersType(TypedDict): database: str role: str warehouse: str + oauth2_client_info: NotRequired[dict[str, Any] | None] + embedded_credentials: NotRequired[dict[str, Any] | None] + + +# Snowflake driver arguments that choose who a connection signs in as. Embedded +# guests sign in as the embedded account only, so none of the connection's own +# may reach the driver on their login. +_CONNECTION_IDENTITY_ARGUMENTS = ( + "user", + "password", + "role", + "authenticator", + "token", + "private_key", + "private_key_file", + "private_key_file_pwd", + "private_key_pwd", + "passcode", + "passcode_in_password", +) class SnowflakeEngineSpec(PostgresBaseEngineSpec): @@ -246,6 +290,11 @@ def prepare_identifier( "$.auth_params.privatekey_body": "Private Key Body", "$.auth_params.privatekey_pass": "Private Key Password", "$.oauth2_client_info.secret": "OAuth2 Client Secret", + # The username is deliberately left visible: an admin needs to be able to see + # which Snowflake user embedded queries run as without having to re-enter it. + "$.embedded_credentials.password": "Embedded Fallback Password", + "$.embedded_credentials.privatekey_body": "Embedded Fallback Private Key", + "$.embedded_credentials.privatekey_pass": "Embedded Fallback Key Passphrase", } _time_grain_expressions = { @@ -286,6 +335,11 @@ def prepare_identifier( # OAuth 2.0 support supports_oauth2: bool = True + + # Snowflake's `impersonate_user` below sets `authenticator=oauth` whether or not a + # token exists, so the fallback path in `Database._get_sqla_engine` -- which skips + # impersonation entirely -- is what lets a stored username/password authenticate. + supports_embedded_credential_fallback: bool = True # `CustomSnowflakeAuthError` is only matched via `isinstance()` (see the # metaclass docstring above), so it's paired with `OAuth2TokenRefreshError` # (a real subclass) to keep `refresh_oauth2_token`'s `except` clause working. @@ -370,6 +424,52 @@ def impersonate_user( return url, engine_kwargs + @classmethod + def apply_embedded_credentials( + cls, + database: Database, + credentials: dict[str, str], + url: URL, + engine_kwargs: dict[str, Any], + ) -> tuple[URL, dict[str, Any]]: + """ + Apply an embedded guest credential, by password or by key pair. + + Called instead of :meth:`impersonate_user`, which above sets + ``authenticator=oauth`` whether or not a token exists -- skipping it is what + lets a stored credential authenticate at all, and is why this engine opts + into the fallback. + + The base implementation puts the username and, for password authentication, + the password on the URL. A key pair cannot go there: the driver takes it as + DER bytes in ``connect_args``. + + Guests run with the fallback user's default role, not the connection's + ``role``: the connection form always writes one into the URI, and a + least-privilege fallback user -- or a programmatic access token restricted + to its own role -- is not granted it, so Snowflake would refuse the session. + """ + url, engine_kwargs = super().apply_embedded_credentials( + database, credentials, url, engine_kwargs + ) + # Everything that signs in as the connection's own account goes: the + # driver merges the URI query and `connect_args` into its arguments, so a + # `user`/`password`, key or token left in either would win over the + # embedded account. `authenticator=SNOWFLAKE_JWT` would also make the + # driver expect a key pair from a password guest; it picks key-pair + # sign-in itself when the embedded credential supplies `private_key`. + url = url.difference_update_query(_CONNECTION_IDENTITY_ARGUMENTS) + connect_args = engine_kwargs.get("connect_args", {}) + for argument in _CONNECTION_IDENTITY_ARGUMENTS: + connect_args.pop(argument, None) + + if credentials.get("auth_method") == "keypair": + engine_kwargs.setdefault("connect_args", {})["private_key"] = ( + cls._load_private_key(credentials) + ) + + return url, engine_kwargs + @classmethod def get_oauth2_authorization_uri( cls, @@ -640,20 +740,14 @@ def validate_parameters( def parameters_json_schema(cls) -> Any: """ Return configuration parameters as OpenAPI. - """ - if not cls.parameters_schema: - return None - ma_plugin = MarshmallowPlugin() - spec = APISpec( - title="Database Parameters", - version="1.0.0", - openapi_version="3.0.0", - plugins=[ma_plugin], - ) - - spec.components.schema(cls.__name__, schema=cls.parameters_schema) - return spec.to_dict()["components"]["schemas"][cls.__name__] + Delegates rather than inheriting: this spec is not a ``BasicParametersMixin`` + user -- its ``build_sqlalchemy_uri`` takes ``SnowflakeParametersType``, which + the mixin's signature does not admit -- but it needs the same schema, + including the ``x-encrypted-extra`` tagging and the embedded-credential + gating. + """ + return build_parameters_json_schema(cls) @staticmethod def update_params_from_encrypted_extra( @@ -672,6 +766,13 @@ def update_params_from_encrypted_extra( if connect_args.get("authenticator") == "oauth": return + # The guest path skips impersonation, leaving `authenticator` unset, so the + # check above does not catch it. Its credential has already been applied by + # `apply_embedded_credentials`; return before the connection's own + # `auth_params` can be layered on top of it. + if database.embedded_fallback_applies(): + return + if not database.encrypted_extra: return try: @@ -685,26 +786,9 @@ def update_params_from_encrypted_extra( return connect_args = params.setdefault("connect_args", {}) if auth_method == "keypair": - privatekey_body = auth_params.get("privatekey_body", None) - key = None - if privatekey_body: - key = privatekey_body.encode() - else: - with open(auth_params["privatekey_path"], "rb") as key_temp: - key = key_temp.read() - privatekey_pass = auth_params.get("privatekey_pass", None) - password = privatekey_pass.encode() if privatekey_pass is not None else None - p_key = serialization.load_pem_private_key( - key, - password=password, - backend=default_backend(), + connect_args["private_key"] = SnowflakeEngineSpec._load_private_key( + auth_params ) - pkb = p_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - connect_args["private_key"] = pkb else: allowed_extra_auths = app.config["ALLOWED_EXTRA_AUTHENTICATIONS"].get( "snowflake", {} @@ -717,3 +801,25 @@ def update_params_from_encrypted_extra( f"must be listed in 'ALLOWED_EXTRA_AUTHENTICATIONS' config" ) connect_args["auth"] = snowflake_auth(**auth_params) + + @staticmethod + def _load_private_key(auth_params: dict[str, Any]) -> bytes: + """Convert a PEM private key into the bytes required by Snowflake.""" + if privatekey_body := auth_params.get("privatekey_body"): + key = privatekey_body.encode() + else: + with open(auth_params["privatekey_path"], "rb") as key_file: + key = key_file.read() + + privatekey_pass = auth_params.get("privatekey_pass") + password = privatekey_pass.encode() if privatekey_pass else None + private_key = serialization.load_pem_private_key( + key, + password=password, + backend=default_backend(), + ) + return private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) diff --git a/superset/models/core.py b/superset/models/core.py index f88bce16b7a9..559a1ed09e8e 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -25,6 +25,7 @@ import logging import textwrap import threading +import weakref from ast import literal_eval from contextlib import ( AbstractContextManager, @@ -45,6 +46,7 @@ import sshtunnel from flask import current_app as app, g, has_app_context from flask_appbuilder import Model +from flask_babel import gettext as __ from marshmallow.exceptions import ValidationError from sqlalchemy import ( Boolean, @@ -58,7 +60,7 @@ Table as SqlaTable, Text, ) -from sqlalchemy.engine import Connection, Dialect, Engine +from sqlalchemy.engine import Connection, Dialect, Engine, ExceptionContext from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.url import URL from sqlalchemy.exc import NoSuchModuleError @@ -71,11 +73,16 @@ from superset import db, db_engine_specs, is_feature_enabled from superset.commands.database.exceptions import DatabaseInvalidError -from superset.constants import LRU_CACHE_MAX_SIZE, PASSWORD_MASK +from superset.constants import ( + EMBEDDED_CREDENTIALS_KEY, + LRU_CACHE_MAX_SIZE, + PASSWORD_MASK, +) from superset.databases.error_provenance import mark_database_engine_error from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import MetricType, TimeGrain -from superset.exceptions import SupersetGenericDBErrorException +from superset.errors import ErrorLevel, SupersetError, SupersetErrorType +from superset.exceptions import SupersetErrorException, SupersetGenericDBErrorException from superset.extensions import ( cache_manager, encrypted_field_factory, @@ -117,6 +124,42 @@ _ENGINE_CACHE: dict[tuple[int, str, str], Engine] = {} _ENGINE_CACHE_LOCK = threading.Lock() +# Engines that authenticate an embedded guest with the fallback credential. A failed +# sign-in on them is logged for administrators (see +# ``Database._log_rejected_embedded_credential``). +_EMBEDDED_FALLBACK_ENGINES: weakref.WeakSet[Engine] = weakref.WeakSet() +# Set on an exception once its rejection is logged, so a path that both dispatches +# ``handle_error`` and catches the error itself logs it once. +_EMBEDDED_REJECTION_LOGGED = "_superset_embedded_rejection_logged" + + +def _log_embedded_rejection( + database_id: int | None, + database_name: str, + *exceptions: BaseException | None, +) -> None: + """ + Log one rejected embedded sign-in, however many paths report it. + + Only the exception class is logged: the driver's message can carry the username. + """ + raised = [ex for ex in exceptions if ex is not None] + if not raised or any( + getattr(ex, _EMBEDDED_REJECTION_LOGGED, False) for ex in raised + ): + return + for ex in raised: + setattr(ex, _EMBEDDED_REJECTION_LOGGED, True) + # Name the driver's own exception class whether or not SQLAlchemy wrapped it. + original = getattr(raised[0], "orig", None) or raised[0] + logger.warning( + "Embedded fallback credential rejected for database %s (id=%s): %s", + database_name, + database_id, + type(original).__name__, + ) + + if TYPE_CHECKING: from superset_core.queries.types import AsyncQueryHandle, QueryOptions, QueryResult @@ -747,7 +790,31 @@ def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901 effective_username = self.get_impersonation_username(sqlalchemy_url) - oauth2_config = self.get_oauth2_config() + embedded_fallback = self.embedded_fallback_applies() + # Ahead of `get_oauth2_config()`, which raises on an incomplete client. + if self.embedded_guest_missing_oauth2_client(): + # Impersonating a guest here would run on whatever identity the engine + # derives from the URL -- for Snowflake, the base account, with no error + # or warning. + raise SupersetErrorException( + SupersetError( + message=__( + "This dashboard's database connection impersonates its " + "users but has no OAuth2 client information, so embedded " + "viewers cannot be served. Its OAuth2 and embedded viewer " + "settings may have been lost, for example when the " + "dashboard was imported from another environment. An " + "administrator needs to re-enter them on the database " + "connection." + ), + error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR, + level=ErrorLevel.ERROR, + ), + status=400, + ) + # Embedded guests do not use OAuth2 tokens or redirect URLs, including when + # a worker restores their identity without a request context. + oauth2_config = None if embedded_fallback else self.get_oauth2_config() access_token = ( get_oauth2_access_token( oauth2_config, @@ -761,7 +828,55 @@ def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901 masked_url = self.get_password_masked_url(sqlalchemy_url) logger.debug("Database._get_sqla_engine(). Masked URL: %s", str(masked_url)) - if self.impersonate_user: + if embedded_fallback: + embedded_credentials = self.get_embedded_fallback_credentials() + if embedded_credentials is None: + # Falling through to the base credential here is the failure mode this + # whole feature exists to prevent: an embedded viewer would silently + # query as whichever user the connection URL carries. That happens on + # a per-user OAuth2 connection with no (or a half-filled) + # credential configured -- none of which the viewer or + # the dashboard author can see. Refuse instead, with a message an + # administrator can act on. + raise SupersetErrorException( + SupersetError( + message=__( + "This dashboard's database connection authenticates users " + "individually and has no credential configured for " + "embedded viewers, so this query cannot run." + ), + error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR, + level=ErrorLevel.ERROR, + ), + # A configuration problem, not a server fault. + status=400, + ) + + # Deliberately *instead of* impersonation, not alongside it, and this is + # load-bearing twice over. First, an engine spec that opts in sets its + # OAuth2 authenticator during impersonation whether or not a token exists, + # so skipping it is what lets the stored credential authenticate at all. + # Second, `effective_username` above resolves to `g.user.username`, and a + # `GuestUser` carries whatever username the guest token claims -- so going + # through impersonation would let whoever mints the token choose the + # connection identity. + # + # How the credential reaches the driver is the engine's business -- a + # password on the URL, a PEM key in connect_args. Both the URL and the + # engine kwargs participate in the cache key below, keeping guest and + # OAuth engines separate even when they use the same database username. + sqlalchemy_url, engine_kwargs = ( + self.db_engine_spec.apply_embedded_credentials( + self, + embedded_credentials, + sqlalchemy_url, + engine_kwargs, + ) + ) + # Hooks must receive the selected connection identity, not the + # username claim supplied by the guest token. + effective_username = sqlalchemy_url.username + elif self.impersonate_user: sqlalchemy_url, engine_kwargs = self.db_engine_spec.impersonate_user( self, effective_username, @@ -820,12 +935,39 @@ def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901 except Exception as ex: raise self.db_engine_spec.get_dbapi_mapped_exception(ex) from ex sqla.event.listen(engine, "handle_error", mark_database_engine_error) + if embedded_fallback: + self._log_rejected_embedded_credential(engine) self.db_engine_spec.register_engine_events(engine) if cache_key is not None: with _ENGINE_CACHE_LOCK: _ENGINE_CACHE[cache_key] = engine return engine + def _log_rejected_embedded_credential(self, engine: Engine) -> None: + """ + Log when the database refuses an embedded guest's fallback credential. + + Viewers only ever see a generic error, so this is how an administrator + finds a wrong password, an expired token or a mismatched key. Only the + exception class is logged: the driver's message can carry the username. + """ + # Captured now: a cached engine can outlive this instance's session. + database_id, database_name = self.id, self.database_name + _EMBEDDED_FALLBACK_ENGINES.add(engine) + + def log_rejection(context: ExceptionContext) -> None: + # No connection means the failure happened while connecting, not in + # a statement. + if context.connection is None: + _log_embedded_rejection( + database_id, + database_name, + context.original_exception, + context.sqlalchemy_exception, + ) + + sqla.event.listen(engine, "handle_error", log_rejection) + def add_database_to_signature( self, func: Callable[..., None], @@ -861,7 +1003,17 @@ def open_connection() -> Iterator[Connection]: source=source, ) as engine: with check_for_oauth2(self): - with closing(engine.raw_connection()) as conn: + try: + raw_connection = engine.raw_connection() + except Exception as ex: + # SQLAlchemy 2 doesn't dispatch ``handle_error`` for a + # failed ``raw_connection()``, which is how chart data on + # a physical table connects, so log a rejected embedded + # sign-in here too (sc-127710). + if engine in _EMBEDDED_FALLBACK_ENGINES: + _log_embedded_rejection(self.id, self.database_name, ex) + raise + with closing(raw_connection) as conn: yield conn with self._open_with_oauth2_retry(open_connection) as conn: @@ -1444,6 +1596,11 @@ def get_encrypted_extra(self) -> dict[str, Any]: # pylint: disable=invalid-name def update_params_from_encrypted_extra(self, params: dict[str, Any]) -> None: self.db_engine_spec.update_params_from_encrypted_extra(self, params) + # Superset's own settings, never driver arguments, whatever the engine's + # handler copies across. A connection switched to an engine whose handler + # copies all of `encrypted_extra` would otherwise fail every connect. + params.pop(EMBEDDED_CREDENTIALS_KEY, None) + params.pop("oauth2_client_info", None) def get_table(self, table: Table) -> SqlaTable: extra = self.get_extra() @@ -1623,6 +1780,119 @@ def make_sqla_column_compatible( sqla_col.key = label_expected return sqla_col + def embedded_fallback_applies(self) -> bool: + """ + Is this request one the embedded fallback credential governs? + + True when every condition *except* the presence of a stored credential holds: + + 1. the deployment opted in via ``EMBEDDED_CREDENTIAL_FALLBACK``; + 2. the engine opted in via ``supports_embedded_credential_fallback``; + 3. the current principal is an embedded guest, and embedding is enabled; + 4. the connection uses per-user OAuth2 with impersonation enabled, rather + than an ordinary service-account connection. + + Split out from :meth:`get_embedded_fallback_credentials` so that the two + callers cannot drift: the credential lookup adds the stored-credential + condition on top, while ``_get_sqla_engine`` uses this to tell "no fallback + here, carry on" apart from "a guest this connection has no credential for", + which it refuses rather than serving on the base credential. + """ + return self._is_embedded_guest_on_impersonating_connection() and ( + self._has_embedded_oauth2_client() + ) + + def embedded_guest_missing_oauth2_client(self) -> bool: + """ + Is this an embedded guest on a connection that has lost its OAuth2 client? + + Conditions 1-3 of :meth:`embedded_fallback_applies` hold and impersonation is + on, but no usable OAuth2 client is configured. That is the shape a connection + takes when its dashboard or chart is promoted by export and import: bundles + do not carry ``encrypted_extra``, so the copy keeps "Impersonate logged in + user" but loses both its OAuth2 client and its embedded viewer credential. + Serving the guest through impersonation would then run on whatever identity + the engine derives from the URL, so ``_get_sqla_engine`` refuses instead. + """ + return self._is_embedded_guest_on_impersonating_connection() and not ( + self._has_embedded_oauth2_client() + ) + + def _is_embedded_guest_on_impersonating_connection(self) -> bool: + """Conditions 1-3 of :meth:`embedded_fallback_applies`, plus impersonation.""" + if not is_feature_enabled("EMBEDDED_CREDENTIAL_FALLBACK"): + return False + + if not self.db_engine_spec.supports_embedded_credential_fallback: + return False + + if not self.impersonate_user: + return False + + # `get_current_guest_user_if_guest` is a bare isinstance check with no feature + # flag behind it. A `GuestUser` can only reach `g` via the guest-token request + # loader, which is itself gated -- but a security boundary should not rest on + # that implication, so the flag is checked explicitly. + if not is_feature_enabled("EMBEDDED_SUPERSET"): + return False + + return security_manager.get_current_guest_user_if_guest() is not None + + def _has_embedded_oauth2_client(self) -> bool: + """Does the connection have a usable OAuth2 client, global or stored?""" + # Snowflake disables its OAuth2 flow outside requests for alerts and reports. + # A restored guest in an async worker still needs the embedded credential, + # so check deployment-wide client configuration independently of that flow. + if ( + self.db_engine_spec.supports_oauth2 + and self.db_engine_spec.engine_name in app.config["DATABASE_OAUTH2_CLIENTS"] + ): + return True + + client_info = self.get_encrypted_extra().get("oauth2_client_info") + if not isinstance(client_info, dict) or not client_info: + return False + + # Validate the stored client without resolving the unused redirect URL. + # The ordinary OAuth2 flow still resolves it through get_oauth2_config(). + try: + client_info = self.db_engine_spec.resolve_oauth2_client_info( + self, client_info + ).copy() + client_info.pop("redirect_uri", None) + OAuth2ClientConfigSchema(exclude=("redirect_uri",)).load(client_info) + except ValidationError: + return False + return True + + def get_embedded_fallback_credentials(self) -> dict[str, str] | None: + """ + Credentials to authenticate an embedded guest's queries, if any. + + An embedded viewer signs in with a guest token rather than a Superset account, + so there is no per-user OAuth2 token to resolve for them -- and an external + viewer may have no identity on the analytical database at all. This lets one + connection carry a credential used *only* for those requests, while logged-in + users continue to authenticate per-user via OAuth2. + + Returns the credential only when :meth:`embedded_fallback_applies` holds *and* + the engine spec recognises what the connection stores as a *complete* + credential -- a half-filled one is ignored rather than half-applied -- so that + there is no route by which a logged-in user can reach it. + + What counts as complete, and how the credential is then applied to the + connection, belong to the engine: + :meth:`~superset.db_engine_specs.base.BaseEngineSpec.validate_embedded_credentials` + and + :meth:`~superset.db_engine_specs.base.BaseEngineSpec.apply_embedded_credentials`. + This method owns only *when* a credential may be used at all. + """ + if not self.embedded_fallback_applies(): + return None + + credentials = self.get_encrypted_extra().get(EMBEDDED_CREDENTIALS_KEY) + return self.db_engine_spec.validate_embedded_credentials(credentials) + def is_oauth2_enabled(self) -> bool: """ Is OAuth2 enabled in the database for authentication? diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index 37d794d6009c..5c3fc1d6fe09 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -372,6 +372,13 @@ def import_(self) -> Response: the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key_password"}`. type: string + encrypted_extra_secrets: + description: >- + JSON map of sensitive values for masked_encrypted_extra fields + of featured databases. Keys are file paths (e.g., + "databases/db.yaml") and values are JSON objects mapping + JSONPath expressions to secrets. + type: string responses: 200: description: Saved Query import result @@ -409,6 +416,7 @@ def import_(self) -> Response: ssh_tunnel_priv_key_passwords = parse_json_form_field( "ssh_tunnel_private_key_passwords" ) + encrypted_extra_secrets = parse_json_form_field("encrypted_extra_secrets") command = ImportSavedQueriesCommand( contents, @@ -417,6 +425,7 @@ def import_(self) -> Response: ssh_tunnel_passwords=ssh_tunnel_passwords, ssh_tunnel_private_keys=ssh_tunnel_private_keys, ssh_tunnel_priv_key_passwords=ssh_tunnel_priv_key_passwords, + encrypted_extra_secrets=encrypted_extra_secrets, ) command.run() return self.response(200, message="OK") diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index 51950ca766f1..bc9ad9a30f4e 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -146,7 +146,7 @@ msgstr "" msgid "\"%s\" is now the system default theme" msgstr "" -#, no-python-format +#, python-format msgid "% calculation" msgstr "" @@ -166,7 +166,7 @@ msgstr "" msgid "% of grand total" msgstr "" -#, no-python-format +#, python-format msgid "% of parent" msgstr "" @@ -174,7 +174,7 @@ msgstr "" msgid "% of row total" msgstr "" -#, no-python-format +#, python-format msgid "% of total" msgstr "" @@ -656,6 +656,9 @@ msgid "" "\n" msgstr "" +msgid "-----BEGIN PRIVATE KEY-----" +msgstr "" + #, python-format msgid "... and %d more" msgstr "" @@ -746,7 +749,6 @@ msgstr "" msgid "10/90 percentiles" msgstr "" -#. do-not-translate msgid "10000" msgstr "" @@ -2476,6 +2478,9 @@ msgstr "" msgid "August" msgstr "" +msgid "Authentication method" +msgstr "" + msgid "Authorization Request URI" msgstr "" @@ -4052,6 +4057,9 @@ msgstr "" msgid "Configure your how you overlay is displayed here." msgstr "" +msgid "Configured" +msgstr "" + msgid "Confirm" msgstr "" @@ -4638,7 +4646,6 @@ msgstr "" msgid "DEC" msgstr "" -#. do-not-translate msgid "DELETE" msgstr "" @@ -5895,14 +5902,12 @@ msgstr "" msgid "Dynamically select grouping columns from a dataset" msgstr "" -#. do-not-translate msgid "ECharts" msgstr "" msgid "ECharts Options (JS object literals)" msgstr "" -#. do-not-translate msgid "EMAIL_REPORTS_CTA" msgstr "" @@ -6109,6 +6114,15 @@ msgstr "" msgid "Embedded dashboard could not be deleted." msgstr "" +msgid "Embedded guest credentials" +msgstr "" + +msgid "Embedded guest credentials are invalid or incomplete. Provide all required fields or clear them to leave embedded viewers unconfigured." +msgstr "" + +msgid "Embedded guest credentials only apply to connections that use OAuth2 with \"Impersonate logged in user\" turned on. Configure OAuth2 client information and turn on impersonation, or clear the embedded credentials." +msgstr "" + msgid "Embedding deactivated." msgstr "" @@ -6304,6 +6318,9 @@ msgstr "" msgid "Enter the required %(dbModelName)s credentials" msgstr "" +msgid "Enter the scopes allowed by your Snowflake integration, for example session:role:ANALYST refresh_token. Without a role scope, Snowflake uses the user's default role." +msgstr "" + msgid "Enter the unique project id for your database." msgstr "" @@ -7428,7 +7445,6 @@ msgstr "" msgid "Further customize how to display each metric" msgstr "" -#. do-not-translate msgid "GROUP BY" msgstr "" @@ -7743,7 +7759,7 @@ msgstr "" msgid "Id of root node of the tree." msgstr "" -msgid "If Presto or Trino, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them. If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property. If Databricks, uses OAuth2 to authenticate as the currently logged on user. If Snowflake or Google Sheets, and OAuth authentication is configured for the database, will run the queries as the currently logged on user via their own OAuth credentials." +msgid "If Presto or Trino, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them. If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property. If Databricks, uses OAuth2 to authenticate as the currently logged on user. If Snowflake or Google Sheets, and OAuth authentication is configured for the database, will run the queries as the currently logged on user via their own OAuth2 credentials. Snowflake OAuth2 connections also require it for embedded guest credentials." msgstr "" msgid "If a metric is specified, sorting will be done based on the metric value" @@ -8287,6 +8303,9 @@ msgstr "" msgid "Key Prefix" msgstr "" +msgid "Key pair" +msgstr "" + msgid "Keyboard shortcuts" msgstr "" @@ -9502,7 +9521,6 @@ msgstr "" msgid "N/A" msgstr "" -#. do-not-translate msgid "NOT GROUPED BY" msgstr "" @@ -10110,7 +10128,6 @@ msgstr "" msgid "OR" msgstr "" -#. do-not-translate msgid "OVERWRITE" msgstr "" @@ -11013,6 +11030,12 @@ msgstr "" msgid "Private Key Password" msgstr "" +msgid "Private key (PEM)" +msgstr "" + +msgid "Private key passphrase (optional)" +msgstr "" + msgid "Proceed" msgstr "" @@ -11372,6 +11395,9 @@ msgstr "" msgid "Remove dependency" msgstr "" +msgid "Remove embedded guest credentials" +msgstr "" + msgid "Remove filter" msgstr "" @@ -11636,6 +11662,9 @@ msgstr "" msgid "Required to mirror range filters, including the time range." msgstr "" +msgid "Requires OAuth2 client information and “Impersonate logged in user” (Advanced → Security)." +msgstr "" + msgid "Resample" msgstr "" @@ -13971,7 +14000,6 @@ msgstr "" msgid "TABLES" msgstr "" -#. do-not-translate msgid "TEMPORAL_RANGE" msgstr "" @@ -14671,16 +14699,16 @@ msgstr "" msgid "The password reset was successful" msgstr "" -msgid "The passwords for the databases below are needed in order to import them together with the charts. Please note that the \"Secure Extra\" and \"Certificate\" sections of the database configuration are not present in export files, and should be added manually after the import if they are needed." +msgid "The passwords for the databases below are needed in order to import them together with the charts. Please note that the \"Certificate\" section of the database configuration is not present in export files, and should be added manually after the import if it is needed." msgstr "" -msgid "The passwords for the databases below are needed in order to import them together with the dashboards. Please note that the \"Secure Extra\" and \"Certificate\" sections of the database configuration are not present in export files, and should be added manually after the import if they are needed." +msgid "The passwords for the databases below are needed in order to import them together with the dashboards. Please note that the \"Certificate\" section of the database configuration is not present in export files, and should be added manually after the import if it is needed." msgstr "" -msgid "The passwords for the databases below are needed in order to import them together with the datasets. Please note that the \"Secure Extra\" and \"Certificate\" sections of the database configuration are not present in export files, and should be added manually after the import if they are needed." +msgid "The passwords for the databases below are needed in order to import them together with the datasets. Please note that the \"Certificate\" section of the database configuration is not present in export files, and should be added manually after the import if it is needed." msgstr "" -msgid "The passwords for the databases below are needed in order to import them together with the saved queries. Please note that the \"Secure Extra\" and \"Certificate\" sections of the database configuration are not present in export files, and should be added manually after the import if they are needed." +msgid "The passwords for the databases below are needed in order to import them together with the saved queries. Please note that the \"Certificate\" section of the database configuration is not present in export files, and should be added manually after the import if it is needed." msgstr "" msgid "The passwords for the databases below are needed in order to import them." @@ -15536,6 +15564,12 @@ msgstr "" msgid "This dashboard will be restored to the following version:" msgstr "" +msgid "This dashboard's database connection authenticates users individually and has no credential configured for embedded viewers, so this query cannot run." +msgstr "" + +msgid "This dashboard's database connection impersonates its users but has no OAuth2 client information, so embedded viewers cannot be served. Its OAuth2 and embedded viewer settings may have been lost, for example when the dashboard was imported from another environment. An administrator needs to re-enter them on the database connection." +msgstr "" + msgid "This database does not allow creating tables from queries (CTAS). Please contact your administrator for more assistance." msgstr "" @@ -16697,6 +16731,9 @@ msgstr "" msgid "Used internally to identify the plugin. Should be set to the package name from the pluginʼs package.json" msgstr "" +msgid "Used only when a dashboard is viewed through an embedded guest token. Every embedded viewer of this connection queries as this one user, so it should be a dedicated account with the narrowest access those dashboards need. Logged-in users are unaffected and continue to authenticate individually via OAuth2." +msgstr "" + msgid "Used to summarize a set of data by grouping together multiple statistics along two axes. Examples: Sales numbers by region and month, tasks by status and assignee, active users by age and location. Not the most visually stunning visualization, but highly informative and versatile." msgstr "" @@ -17011,11 +17048,9 @@ msgstr "" msgid "WED" msgstr "" -#. do-not-translate msgid "WFS" msgstr "" -#. do-not-translate msgid "WMS" msgstr "" @@ -17467,7 +17502,6 @@ msgstr "" msgid "X-scale interval" msgstr "" -#. do-not-translate msgid "XYZ" msgstr "" @@ -18066,7 +18100,6 @@ msgstr "" msgid "bfill" msgstr "" -#. do-not-translate msgid "bolt" msgstr "" @@ -18155,7 +18188,6 @@ msgstr "" msgid "create dataset from SQL query" msgstr "" -#. do-not-translate msgid "crontab" msgstr "" @@ -18222,7 +18254,6 @@ msgstr "" msgid "deck.gl Contour" msgstr "" -#. do-not-translate msgid "deck.gl Geojson" msgstr "" @@ -18274,7 +18305,6 @@ msgstr "" msgid "documentation" msgstr "" -#. do-not-translate msgid "dttm" msgstr "" @@ -18299,6 +18329,9 @@ msgstr "" msgid "e.g. default" msgstr "" +msgid "e.g. embedded_viewer" +msgstr "" + msgid "e.g. hive_metastore" msgstr "" @@ -18335,7 +18368,6 @@ msgstr "" msgid "entries" msgstr "" -#. do-not-translate msgid "error_message" msgstr "" @@ -18613,19 +18645,15 @@ msgstr "" msgid "p-value precision" msgstr "" -#. do-not-translate msgid "p1" msgstr "" -#. do-not-translate msgid "p5" msgstr "" -#. do-not-translate msgid "p95" msgstr "" -#. do-not-translate msgid "p99" msgstr "" @@ -18638,7 +18666,6 @@ msgstr "" msgid "permalink state not found" msgstr "" -#. do-not-translate msgid "pivoted_xlsx" msgstr "" @@ -18725,7 +18752,6 @@ msgstr "" msgid "schedule \"%s\" runs more often than the minimum interval" msgstr "" -#. do-not-translate msgid "schema1,schema2" msgstr "" @@ -18754,7 +18780,6 @@ msgstr "" msgid "show_values_as is only supported for additive aggregates (sum, count); got non-additive operator(s) for: %(metrics)s." msgstr "" -#. do-not-translate msgid "sql" msgstr "" @@ -18788,7 +18813,6 @@ msgstr "" msgid "sum" msgstr "" -#. do-not-translate msgid "superset.example.com" msgstr "" @@ -18904,7 +18928,6 @@ msgstr "" msgid "year" msgstr "" -#. do-not-translate msgid "your-project-1234-a1" msgstr "" diff --git a/superset/utils/cache_keys.py b/superset/utils/cache_keys.py index 2140e5df5de8..fc589f041460 100644 --- a/superset/utils/cache_keys.py +++ b/superset/utils/cache_keys.py @@ -38,6 +38,38 @@ def add_impersonation_cache_key_if_needed( Add a per-user cache-key when the DB connection is configured for per-user caching, no-op otherwise. """ + # The embedded fallback credential replaces the connection identity *without* + # going through impersonation (`Database._get_sqla_engine`), so none of the + # conditions below would notice it. Its result must never share a DATA-cache + # entry -- nor, since the async `task_key` is the query cache key, a SHARED + # chart task -- with a logged-in user's per-user OAuth2 result. That is + # correctness rather than a caching preference, so it is checked ahead of, and + # independently of, the opt-in flags. + # + # The engine spec owns the key material -- see + # `BaseEngineSpec.get_embedded_credential_cache_key` for why it is keyed on the + # configured identity rather than on `g.user.username`. + # A separate field prevents collisions with a logged-in username that equals + # the engine-provided guest key. + if embedded_credentials := database.get_embedded_fallback_credentials(): + cache_dict["embedded_credentials_key"] = ( + database.db_engine_spec.get_embedded_credential_cache_key( + embedded_credentials + ) + ) + return + + if ( + database.embedded_fallback_applies() + or database.embedded_guest_missing_oauth2_client() + ): + # Missing credentials -- or a missing OAuth2 client, as after an import -- + # must not allow a warm logged-in cache entry to bypass the refusal at engine + # creation. Use a separate marker so it cannot collide with an engine's + # configured credential identity or a user's username. + cache_dict["embedded_credentials_missing"] = True + return + extra = json_loads(database.extra or "{}") if ( ( diff --git a/superset/utils/json.py b/superset/utils/json.py index e311b2ea269c..d2d91a996cd5 100644 --- a/superset/utils/json.py +++ b/superset/utils/json.py @@ -273,6 +273,11 @@ def redact_sensitive( for json_path in sensitive_fields: jsonpath_expr = parse(json_path) for match in jsonpath_expr.find(redacted_payload): + # An empty value has nothing to hide. Masking it would tell the admin a + # secret is stored when none is, and send back a mask paired with, + # say, a cleared username -- which then reads as half-filled. + if match.value in (None, ""): + continue match.context.value[match.path.fields[0]] = PASSWORD_MASK return redacted_payload @@ -362,6 +367,37 @@ def get_masked_fields( return masked +def unrevealable_masked_fields( + old_payload: dict[str, Any] | None, + new_payload: dict[str, Any], + sensitive_fields: set[str], +) -> list[str]: + """ + Returns masked fields in ``new_payload`` that ``old_payload`` cannot reveal. + + :func:`reveal_sensitive` swaps a mask back for the stored value at the same + path. Where the stored payload has no value there, the mask would be kept and + stored as if it were the secret, so callers that must not do that (imports + overwriting a connection) use this to ask for the real value instead. A + stored mask counts as no value: earlier releases saved one when an import + had nothing to reveal, and revealing it only hands the mask back. + + :param old_payload: The stored payload, if any + :param new_payload: The incoming payload, possibly containing masks + :param sensitive_fields: The set of fields to check, as JSONPath expressions + :returns: JSONPath expressions for masked fields with nothing to reveal + """ + return [ + field + for field in get_masked_fields(new_payload, sensitive_fields) + if old_payload is None + or not any( + match.value not in (None, "", PASSWORD_MASK) + for match in parse(field).find(old_payload) + ) + ] + + def set_masked_fields( payload: dict[str, Any], path_values: dict[str, Any], diff --git a/superset/utils/oauth2.py b/superset/utils/oauth2.py index a6938c01b0a1..f97f0e2d3e14 100644 --- a/superset/utils/oauth2.py +++ b/superset/utils/oauth2.py @@ -585,6 +585,24 @@ class OAuth2ClientConfigSchema(Schema): ) +def _is_guest_request() -> bool: + """ + Is the current principal an embedded guest? + + Guests authenticate with a guest token rather than a Superset account, so there is + no per-user OAuth2 token to resolve for them and no route by which they could + authorize one. + """ + # Prevent circular import. + from superset import security_manager + + user = getattr(g, "user", None) + if user is None: + return False + + return security_manager.is_guest_user(user) + + @contextmanager def check_for_oauth2(database: Database) -> Iterator[None]: """ @@ -596,6 +614,10 @@ def check_for_oauth2(database: Database) -> Iterator[None]: if ( not is_oauth2_retry_active() and database.is_oauth2_enabled() + # The `OAuth2TokenRefreshError` arm below reaches the dance without + # consulting `needs_oauth2`, so the guest check it performs has to be + # repeated here or an embedded viewer takes that arm. + and not _is_guest_request() and ( isinstance(ex, OAuth2TokenRefreshError) or database.db_engine_spec.needs_oauth2(ex) diff --git a/tests/unit_tests/charts/commands/data/test_get_data_command.py b/tests/unit_tests/charts/commands/data/test_get_data_command.py index 3676c9457fc4..a2b4b9fa47e4 100644 --- a/tests/unit_tests/charts/commands/data/test_get_data_command.py +++ b/tests/unit_tests/charts/commands/data/test_get_data_command.py @@ -445,3 +445,31 @@ def test_get_query_handles_parsing_error_with_null_sql_value() -> None: assert "query" not in result assert result["error"] == "Parsing error occurred" assert result["language"] == "sql" + + +@pytest.mark.parametrize("entry", ["run", "execute"]) +def test_database_error_while_building_the_query_is_a_failed_query(entry: str) -> None: + """ + A connection failure while the query is being built -- a virtual dataset's + default-schema lookup connects -- is reported as a failed query (a 400), + not left to escape as an unexpected server error (an HTML 500). + """ + from sqlalchemy.exc import OperationalError + + mock_query_context = Mock(spec=QueryContext) + mock_query_context.result_type = ChartDataResultType.FULL + error = OperationalError( + "connect", {}, Exception("Incorrect username or password was specified.") + ) + mock_query_context.get_payload.side_effect = error + mock_query_context.get_payload_result.side_effect = error + database = Mock() + database.db_engine_spec.extract_error_message.return_value = ( + "Incorrect username or password was specified." + ) + mock_query_context.datasource = Mock(database=database) + + with pytest.raises(ChartDataQueryFailedError) as excinfo: + getattr(ChartDataCommand(mock_query_context), entry)() + + assert "Incorrect username or password" in excinfo.value.message diff --git a/tests/unit_tests/commands/databases/create_test.py b/tests/unit_tests/commands/databases/create_test.py index 5e4d23ab6fb5..4526d1ae3599 100644 --- a/tests/unit_tests/commands/databases/create_test.py +++ b/tests/unit_tests/commands/databases/create_test.py @@ -21,6 +21,7 @@ from pytest_mock import MockerFixture from superset.commands.database.create import CreateDatabaseCommand +from superset.commands.database.exceptions import MissingOAuth2TokenError from superset.exceptions import OAuth2RedirectError from superset.extensions import security_manager @@ -155,3 +156,36 @@ def test_create_with_oauth2( ).run() add_permission_view_menu.assert_not_called() + + +@pytest.mark.parametrize( + "error", + [ + OAuth2RedirectError("url", "tab_id", "redirect_uri"), + MissingOAuth2TokenError(), + ], +) +def test_create_succeeds_when_permission_sync_needs_oauth2( + mocker: MockerFixture, + database_with_catalog: MagicMock, + error: Exception, +) -> None: + """ + A valid create is not reported as failed when only the permission sync needs + the creating admin's OAuth2 token (sc-126623). + + The connection test passes on the connection's own settings; listing catalogs + runs through impersonation as the admin, who has no token yet. Reporting a 422 + left the row committed behind it anyway. + """ + database_with_catalog.get_all_catalog_names.side_effect = error + + database = CreateDatabaseCommand( + { + "database_name": "test_database", + "sqlalchemy_uri": "sqlite://", + "impersonate_user": True, + } + ).run() + + assert database is database_with_catalog diff --git a/tests/unit_tests/commands/databases/validate_test.py b/tests/unit_tests/commands/databases/validate_test.py index 12ad416aaa2c..2bd4d69efb98 100644 --- a/tests/unit_tests/commands/databases/validate_test.py +++ b/tests/unit_tests/commands/databases/validate_test.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from typing import Any + import pytest from pytest_mock import MockerFixture @@ -27,6 +29,7 @@ from superset.constants import PASSWORD_MASK from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.utils import json +from tests.unit_tests.conftest import with_feature_flags def test_command(mocker: MockerFixture) -> None: @@ -908,6 +911,232 @@ def test_ssh_tunnel_missing_message_is_interpolated( assert any("server_port" in msg for msg in missing_field_messages) +@pytest.mark.parametrize( + "credentials", + [ + {"username": "embedded_svc"}, + {"password": "pw"}, + {"username": "embedded_svc", "password": ""}, + {"username": " ", "password": "pw"}, + {"username": " \t ", "auth_method": "keypair", "privatekey_body": "PEM"}, + {"username": "embedded_svc", "auth_method": "keypair"}, + {"auth_method": "keypair", "privatekey_body": "-----BEGIN-----"}, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_incomplete_embedded_credentials_from_the_dialog( + mocker: MockerFixture, + credentials: dict[str, str], +) -> None: + """ + An incomplete embedded credential is rejected rather than silently stored. + + ``Database.get_embedded_fallback_credentials`` ignores a credential that is + not complete for its auth method, so saving one leaves a setting that reads as + configured and never applies. Snowflake is a bypass engine, so this has to be + collected alongside the database-name and SSH-tunnel errors rather than in + ``validate_parameters``. + + The connection dialog validates before it folds ``x-encrypted-extra`` + parameters into ``masked_encrypted_extra``, so the value arrives here under + ``parameters``. + """ + mocker.patch("superset.commands.database.validate.DatabaseDAO") + + properties = { + "engine": "snowflake", + "parameters": {"embedded_credentials": credentials}, + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + + error = excinfo.value.errors[0] + assert error.error_type == SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR + # `INVALID_PAYLOAD_SCHEMA_ERROR` is on the blur-time allowlist in + # `useDatabaseValidation`, and `extra.invalid` is what maps the message onto + # the matching form field, so the user sees it before pressing Connect. + assert error.extra["invalid"] == ["embedded_credentials"] + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_incomplete_embedded_credentials_from_the_api(mocker: MockerFixture) -> None: + """ + A direct API caller posts the credential inside ``masked_encrypted_extra``. + """ + mocker.patch("superset.commands.database.validate.DatabaseDAO") + + properties = { + "engine": "snowflake", + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": {"username": "embedded_svc", "password": ""}} + ), + } + command = ValidateDatabaseParametersCommand(properties) + with pytest.raises(InvalidParametersError) as excinfo: + command.run() + + assert excinfo.value.errors[0].extra["invalid"] == ["embedded_credentials"] + + +@pytest.mark.parametrize( + "credentials", + [ + {"username": "embedded_svc", "password": "pw"}, + # The edit flow posts the secrets back as the backend's mask. + {"username": "embedded_svc", "password": PASSWORD_MASK}, + { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": "-----BEGIN PRIVATE KEY-----", + }, + { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": PASSWORD_MASK, + "privatekey_pass": PASSWORD_MASK, + }, + {}, + None, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_complete_or_absent_embedded_credentials_pass( + mocker: MockerFixture, + credentials: dict[str, str] | None, +) -> None: + """ + Complete, empty and absent credentials are all acceptable. + """ + mocker.patch("superset.commands.database.validate.DatabaseDAO") + + properties = { + "engine": "snowflake", + "parameters": {"embedded_credentials": credentials}, + } + ValidateDatabaseParametersCommand(properties).run() + + +@pytest.mark.parametrize( + "credentials", + [ + {"username": "embedded_svc", "password": PASSWORD_MASK}, + { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": PASSWORD_MASK, + }, + "", + {}, + None, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_credential_edits_replace_an_imported_partial_value( + mocker: MockerFixture, credentials: dict[str, str] | str | None +) -> None: + """Validate the edited value rather than the stale imported credential.""" + from superset.databases.schemas import DatabaseValidateParametersSchema + + mocker.patch("superset.commands.database.validate.DatabaseDAO") + properties = DatabaseValidateParametersSchema().load( + { + "engine": "snowflake", + "configuration_method": "dynamic_form", + "parameters": {"embedded_credentials": credentials}, + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": {"username": "embedded_svc"}} + ), + } + ) + ValidateDatabaseParametersCommand(properties).run() + + +@pytest.mark.parametrize( + "disabled_flag", ["EMBEDDED_CREDENTIAL_FALLBACK", "EMBEDDED_SUPERSET"] +) +def test_disabled_embedded_credentials_do_not_block_parameter_validation( + mocker: MockerFixture, disabled_flag: str +) -> None: + """A hidden partial credential must not block testing an unrelated edit.""" + mocker.patch( + "superset.extensions.feature_flag_manager.get_feature_flags", + return_value={ + "EMBEDDED_CREDENTIAL_FALLBACK": True, + "EMBEDDED_SUPERSET": True, + disabled_flag: False, + }, + ) + mocker.patch("superset.commands.database.validate.DatabaseDAO") + ValidateDatabaseParametersCommand( + { + "engine": "snowflake", + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": {"username": "embedded_svc"}} + ), + } + ).run() + + +@pytest.mark.parametrize("source", ["parameters", "masked_encrypted_extra"]) +@pytest.mark.parametrize("credentials", ["not json", "[]", "42", [], False, 0]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_bypass_engine_rejects_malformed_embedded_credentials( + mocker: MockerFixture, source: str, credentials: Any +) -> None: + """Snowflake's bypass cannot skip malformed embedded credential checks.""" + mocker.patch("superset.commands.database.validate.DatabaseDAO") + value = {"embedded_credentials": credentials} + properties = { + "engine": "snowflake", + source: value if source == "parameters" else json.dumps(value), + } + with pytest.raises(InvalidParametersError) as excinfo: + ValidateDatabaseParametersCommand(properties).run() + error = excinfo.value.errors[0] + assert error.extra["invalid"] == ["embedded_credentials"] + assert "invalid or incomplete" in error.message + + +@pytest.mark.parametrize("source", ["parameters", "masked_encrypted_extra"]) +@pytest.mark.parametrize("valid", [True, False]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_parameter_validation_uses_the_engine_credential_validator( + mocker: MockerFixture, source: str, valid: bool +) -> None: + """Custom credentials use the query-time hook even on bypass engines.""" + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + class TokenEngineSpec(SnowflakeEngineSpec): + @classmethod + def validate_embedded_credentials( + cls, credentials: Any + ) -> dict[str, str] | None: + if isinstance(credentials, dict) and credentials.get("token"): + return credentials + return None + + mocker.patch("superset.commands.database.validate.DatabaseDAO") + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=TokenEngineSpec, + ) + credentials = ( + {"token": "engine-token"} if valid else {"username": "svc", "password": "pw"} + ) + value = {"embedded_credentials": json.dumps(credentials)} + properties = { + "engine": "snowflake", + source: value if source == "parameters" else json.dumps(value), + } + command = ValidateDatabaseParametersCommand(properties) + if valid: + command.run() + else: + with pytest.raises(InvalidParametersError): + command.run() + + @pytest.mark.parametrize( "rebuilt_uri, expected_uri", [ diff --git a/tests/unit_tests/common/test_query_context_processor.py b/tests/unit_tests/common/test_query_context_processor.py index 1af98edec69b..9aec465a26e6 100644 --- a/tests/unit_tests/common/test_query_context_processor.py +++ b/tests/unit_tests/common/test_query_context_processor.py @@ -75,6 +75,11 @@ def _wire_contribution_totals(mock_query_context: MagicMock) -> None: for query in mock_query_context.queries: assert query.datasource is not None query.datasource.database.extra = "{}" + query.datasource.database.get_embedded_fallback_credentials.return_value = None + query.datasource.database.embedded_fallback_applies.return_value = False + query.datasource.database.embedded_guest_missing_oauth2_client.return_value = ( + False + ) def _query_timing() -> QueryTiming: @@ -1626,6 +1631,9 @@ def test_get_df_payload_validates_before_cache_key_generation(): mock_datasource.cache_timeout = None mock_datasource.database.db_engine_spec.engine = "postgresql" mock_datasource.database.extra = "{}" + mock_datasource.database.get_embedded_fallback_credentials.return_value = None + mock_datasource.database.embedded_fallback_applies.return_value = False + mock_datasource.database.embedded_guest_missing_oauth2_client.return_value = False mock_datasource.get_extra_cache_keys.return_value = [] mock_datasource.changed_on = None @@ -1833,6 +1841,9 @@ def test_cache_key_excludes_contribution_totals(): mock_datasource = MagicMock() mock_datasource.uid = "test_datasource" mock_datasource.database.extra = "{}" + mock_datasource.database.get_embedded_fallback_credentials.return_value = None + mock_datasource.database.embedded_fallback_applies.return_value = False + mock_datasource.database.embedded_guest_missing_oauth2_client.return_value = False mock_datasource.get_extra_cache_keys.return_value = [] # Create query with contribution post-processing that includes contribution_totals @@ -1887,6 +1898,9 @@ def test_cache_key_preserves_other_post_processing_options(): mock_datasource = MagicMock() mock_datasource.uid = "test_datasource" mock_datasource.database.extra = "{}" + mock_datasource.database.get_embedded_fallback_credentials.return_value = None + mock_datasource.database.embedded_fallback_applies.return_value = False + mock_datasource.database.embedded_guest_missing_oauth2_client.return_value = False mock_datasource.get_extra_cache_keys.return_value = [] # Create query with contribution post-processing @@ -1938,6 +1952,9 @@ def test_cache_key_non_contribution_post_processing_unchanged(): mock_datasource = MagicMock() mock_datasource.uid = "test_datasource" mock_datasource.database.extra = "{}" + mock_datasource.database.get_embedded_fallback_credentials.return_value = None + mock_datasource.database.embedded_fallback_applies.return_value = False + mock_datasource.database.embedded_guest_missing_oauth2_client.return_value = False mock_datasource.get_extra_cache_keys.return_value = [] # Create query with non-contribution post-processing @@ -1986,6 +2003,9 @@ def test_force_cached_normalizes_totals_query_row_limit(): mock_datasource.changed_on = None mock_datasource.get_extra_cache_keys.return_value = [] mock_datasource.database.extra = "{}" + mock_datasource.database.get_embedded_fallback_credentials.return_value = None + mock_datasource.database.embedded_fallback_applies.return_value = False + mock_datasource.database.embedded_guest_missing_oauth2_client.return_value = False mock_datasource.database.impersonate_user = False mock_datasource.database.db_engine_spec.get_impersonation_key.return_value = None diff --git a/tests/unit_tests/common/test_query_serialization.py b/tests/unit_tests/common/test_query_serialization.py index e924a33fff15..9e0d56001171 100644 --- a/tests/unit_tests/common/test_query_serialization.py +++ b/tests/unit_tests/common/test_query_serialization.py @@ -135,6 +135,9 @@ def test_contribution_totals_round_trip_preserves_cache_key( datasource.changed_on = None datasource.get_extra_cache_keys.return_value = [] datasource.database.extra = "{}" + datasource.database.get_embedded_fallback_credentials.return_value = None + datasource.database.embedded_fallback_applies.return_value = False + datasource.database.embedded_guest_missing_oauth2_client.return_value = False datasource.database.impersonate_user = False datasource.database.db_engine_spec.get_impersonation_key.return_value = None mocker.patch( diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py index e61ce759855c..46a2f0f41203 100644 --- a/tests/unit_tests/databases/api_test.py +++ b/tests/unit_tests/databases/api_test.py @@ -46,6 +46,7 @@ from superset.superset_typing import OAuth2State from superset.utils import json from superset.utils.oauth2 import encode_oauth2_state +from tests.unit_tests.conftest import with_feature_flags from tests.unit_tests.fixtures.common import ( create_columnar_file, create_csv_file, @@ -3046,6 +3047,118 @@ def test_related_objects_limits_dataset_details( assert can_access.call_count == MAX_RELATED_DATASETS +@pytest.mark.parametrize("valid", [True, False]) +def test_partial_update_validates_embedded_credentials_with_the_stored_engine( + mocker: MockerFixture, + session: Session, + client: FlaskClient, + full_api_access: None, + valid: bool, +) -> None: + """PUT retains engine-specific validation when the caller omits the URI.""" + from superset.databases.api import DatabaseRestApi + from superset.models.core import Database + + class TokenEngineSpec(SqliteEngineSpec): + @classmethod + def validate_embedded_credentials( + cls, credentials: Any + ) -> dict[str, str] | None: + if isinstance(credentials, dict) and credentials.get("token"): + return credentials + return None + + mocker.patch.object(DatabaseRestApi.datamodel, "_session", session) + Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member + mocker.patch( + "superset.extensions.feature_flag_manager.get_feature_flags", + return_value={"EMBEDDED_CREDENTIAL_FALLBACK": True, "EMBEDDED_SUPERSET": True}, + ) + mocker.patch.object(Database, "get_db_engine_spec", return_value=TokenEngineSpec) + mocker.patch.object(Database, "get_default_catalog", return_value=None) + mocker.patch("superset.utils.log.DBEventLogger.log") + mocker.patch("superset.commands.database.update.SyncPermissionsCommand.run") + mocker.patch("superset.commands.database.update.get_username", return_value="admin") + database = Database( + database_name="test_db", sqlalchemy_uri="sqlite://", encrypted_extra="{}" + ) + session.add(database) + session.commit() + database_id = database.id + credentials = ( + {"token": "engine-token"} if valid else {"username": "svc", "password": "pw"} + ) + payload = { + "masked_encrypted_extra": json.dumps({"embedded_credentials": credentials}) + } + + response = client.put(f"/api/v1/database/{database_id}", json=payload) + + assert response.status_code == (200 if valid else 400) + session.expire_all() + stored = session.get(Database, database_id) + assert stored is not None + assert json.loads(stored.encrypted_extra) == ( + {"embedded_credentials": credentials} if valid else {} + ) + + +@pytest.mark.parametrize("rename", [False, True]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_removed_embedded_credential_round_trips_through_the_dialog( + mocker: MockerFixture, + session: Session, + client: FlaskClient, + full_api_access: None, + rename: bool, +) -> None: + """ + After the credential is cleared, re-saving what the dialog loaded succeeds. + + The empty password used to come back masked, so the dialog re-posted an + empty username with a mask, which reads as half-filled: every later save of + the connection, even a rename, answered 400 (QA OEF-SECRET-07). + """ + from superset.databases.api import DatabaseRestApi + from superset.models.core import Database + + mocker.patch.object(DatabaseRestApi.datamodel, "_session", session) + Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member + mocker.patch("superset.utils.log.DBEventLogger.log") + mocker.patch("superset.commands.database.update.SyncPermissionsCommand.run") + mocker.patch("superset.commands.database.update.get_username", return_value="admin") + mocker.patch.object(Database, "get_default_catalog", return_value=None) + # The Snowflake override lists specific paths; the base default masks `$.*`. + mocker.patch.object( + SqliteEngineSpec, + "encrypted_extra_sensitive_fields", + {"$.embedded_credentials.password": "Embedded Fallback Password"}, + ) + removed = {"embedded_credentials": {"username": "", "password": ""}} + database = Database( + database_name="test_database", + sqlalchemy_uri="sqlite://", + encrypted_extra=json.dumps(removed), + ) + session.add(database) + session.commit() + database_id = database.id + + loaded = client.get(f"/api/v1/database/{database_id}/connection").json["result"] + assert json.loads(loaded["masked_encrypted_extra"]) == removed + payload = {"masked_encrypted_extra": loaded["masked_encrypted_extra"]} + if rename: + payload["database_name"] = "renamed" + + response = client.put(f"/api/v1/database/{database_id}", json=payload) + + assert response.status_code == 200, response.json + session.expire_all() + stored = session.get(Database, database_id) + assert stored is not None + assert json.loads(stored.encrypted_extra) == removed + + def test_available_hides_legacy_databricks( mocker: MockerFixture, client: FlaskClient, diff --git a/tests/unit_tests/databases/commands/importers/v1/import_test.py b/tests/unit_tests/databases/commands/importers/v1/import_test.py index 94ca05d251a2..050ae10e39c1 100644 --- a/tests/unit_tests/databases/commands/importers/v1/import_test.py +++ b/tests/unit_tests/databases/commands/importers/v1/import_test.py @@ -17,6 +17,7 @@ # pylint: disable=unused-argument, import-outside-toplevel, invalid-name import copy +from typing import TYPE_CHECKING import pytest from flask import current_app @@ -27,6 +28,10 @@ from superset.commands.exceptions import ImportFailedError from superset.constants import PASSWORD_MASK from superset.utils import json +from tests.unit_tests.conftest import with_feature_flags + +if TYPE_CHECKING: + from superset.models.core import Database def test_import_database(mocker: MockerFixture, session: Session) -> None: @@ -344,6 +349,50 @@ def test_import_database_with_masked_encrypted_extra_existing_db( assert encrypted["credentials_info"]["private_key"] != PASSWORD_MASK +@pytest.mark.parametrize("partial", [True, False]) +def test_import_database_keeps_secure_extra_only_for_a_partial_bundle( + mocker: MockerFixture, + session: Session, + partial: bool, +) -> None: + """A database export removing a block, even a carried one, removes it.""" + from superset import security_manager + from superset.commands.database.importers.v1.utils import import_database + from superset.models.core import Database + from tests.integration_tests.fixtures.importexport import ( + database_config_with_masked_encrypted_extra, + ) + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + + config = copy.deepcopy(database_config_with_masked_encrypted_extra) + config["masked_encrypted_extra"] = json.dumps( + { + "other_setting": "kept", + "embedded_credentials": {"username": "svc", "password": "stored"}, + "oauth2_client_info": {"id": "client", "secret": "stored"}, + } + ) + import_database(config) + db.session.flush() + + config = copy.deepcopy(database_config_with_masked_encrypted_extra) + config["masked_encrypted_extra"] = json.dumps( + {"oauth2_client_info": {"id": "client", "secret": PASSWORD_MASK}} + ) + encrypted = json.loads( + import_database( + config, overwrite=True, partial_secure_extra=partial + ).encrypted_extra + ) + + assert encrypted["oauth2_client_info"]["secret"] == "stored" # noqa: S105 + assert ("other_setting" in encrypted) is partial + assert ("embedded_credentials" in encrypted) is partial + + def test_import_database_existing_no_overwrite_backfills_permissions( mocker: MockerFixture, session: Session, @@ -970,3 +1019,314 @@ def test_import_database_masked_encrypted_extra_not_revealed_on_host_change( assert database.password == "newpass" # noqa: S105 encrypted = json.loads(database.encrypted_extra) assert encrypted["my_secret"] == PASSWORD_MASK + + +def test_import_database_never_stores_a_mask_over_an_existing_db( + mocker: MockerFixture, + session: Session, +) -> None: + """ + Overwriting a connection that has no stored value for a masked secret fails. + + A connection promoted by a dashboard bundle exists without its secrets, so + there is nothing to reveal; storing the mask would make it the secret (QA + OEF-SECRET-04). The schema asks for the value first; this is the backstop. + """ + from superset import security_manager + from superset.commands.database.importers.v1.utils import import_database + from superset.commands.exceptions import ImportFailedError + from superset.constants import PASSWORD_MASK + from superset.models.core import Database + from tests.integration_tests.fixtures.importexport import ( + database_config_with_masked_encrypted_extra, + ) + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + + engine = db.session.get_bind() + Database.metadata.create_all(engine) # pylint: disable=no-member + + # The promoted copy: same connection, no encrypted_extra. + config = copy.deepcopy(database_config_with_masked_encrypted_extra) + del config["masked_encrypted_extra"] + existing = import_database(config) + db.session.flush() + + masked = copy.deepcopy(database_config_with_masked_encrypted_extra) + masked["masked_encrypted_extra"] = json.dumps( + {"credentials_info": {"type": "service_account", "private_key": PASSWORD_MASK}} + ) + with pytest.raises(ImportFailedError, match="credentials_info.private_key"): + import_database(masked, overwrite=True) + + assert existing.encrypted_extra is None + + +EMBEDDED_KEYPAIR = { + "username": "EMBED_SVC", + "auth_method": "keypair", + "privatekey_body": "-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n", + "privatekey_pass": "passphrase", +} + + +def _snowflake_config(embedded_credentials: dict[str, str]) -> dict[str, object]: + """A per-user OAuth2 Snowflake connection carrying an embedded credential.""" + return { + "database_name": "imported_snowflake", + "sqlalchemy_uri": "snowflake://base_user@account/db?warehouse=WH", + "uuid": "6c1c5ee7-1d83-4f57-9a8e-1a2b3c4d5e6f", + "version": "1.0.0", + "extra": {}, + "impersonate_user": True, + "masked_encrypted_extra": json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "client-secret"}, + "embedded_credentials": embedded_credentials, + } + ), + } + + +def _import_snowflake_with_stored_keypair(mocker: MockerFixture) -> "Database": + from superset import security_manager + from superset.commands.database.importers.v1.utils import import_database + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + + existing = import_database(_snowflake_config(EMBEDDED_KEYPAIR)) + db.session.flush() + return existing + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=True) +def test_import_database_refuses_embedded_credential_missing_private_key( + mocker: MockerFixture, session: Session +) -> None: + """ + An overwrite whose embedded key-pair credential leaves out the private key + is refused, naming the key, and the stored key stays (sc-127530). + + The dialog and the API refuse the same half-filled credential; without this + the import replaced the stored credential with one no viewer can log in with. + """ + from superset.commands.database.importers.v1.utils import import_database + + existing = _import_snowflake_with_stored_keypair(mocker) + + half_filled = { + "username": "EMBED_SVC", + "auth_method": "keypair", + "privatekey_pass": PASSWORD_MASK, + } + with pytest.raises( + ImportFailedError, match=r"\$\.embedded_credentials\.privatekey_body" + ): + import_database(_snowflake_config(half_filled), overwrite=True) + + stored = json.loads(existing.encrypted_extra)["embedded_credentials"] + assert stored == EMBEDDED_KEYPAIR + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=True) +@pytest.mark.parametrize( + "secure_extra_key", ["masked_encrypted_extra", "encrypted_extra"] +) +def test_import_database_refuses_embedded_credential_as_a_json_string( + mocker: MockerFixture, session: Session, secure_extra_key: str +) -> None: + """ + The value is stored as sent and a string is refused at query time, so however + complete the JSON inside it, an overwrite would lose a working credential. + """ + from superset.commands.database.importers.v1.utils import import_database + + existing = _import_snowflake_with_stored_keypair(mocker) + + config = _snowflake_config(EMBEDDED_KEYPAIR) + del config["masked_encrypted_extra"] + config[secure_extra_key] = json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "client-secret"}, + "embedded_credentials": json.dumps({"username": "svc", "password": "pw"}), + } + ) + with pytest.raises(ImportFailedError, match="invalid or incomplete"): + import_database(config, overwrite=True) + + stored = json.loads(existing.encrypted_extra)["embedded_credentials"] + assert stored == EMBEDDED_KEYPAIR + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=True) +def test_import_database_refuses_new_db_with_incomplete_embedded_credential( + mocker: MockerFixture, session: Session +) -> None: + """A new connection can't be created with a half-filled credential either.""" + from superset import security_manager + from superset.commands.database.importers.v1.utils import import_database + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + + with pytest.raises(ImportFailedError, match=r"\$\.embedded_credentials\.password"): + import_database(_snowflake_config({"username": "EMBED_SVC"})) + + assert db.session.query(Database).count() == 0 + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=True) +def test_import_database_keeps_complete_embedded_credential_on_overwrite( + mocker: MockerFixture, session: Session +) -> None: + """A complete credential, its secrets masked, overwrites and reveals as before.""" + from superset.commands.database.importers.v1.utils import import_database + + _import_snowflake_with_stored_keypair(mocker) + + masked = { + **EMBEDDED_KEYPAIR, + "privatekey_body": PASSWORD_MASK, + "privatekey_pass": PASSWORD_MASK, + } + database = import_database(_snowflake_config(masked), overwrite=True) + + stored = json.loads(database.encrypted_extra)["embedded_credentials"] + assert stored == EMBEDDED_KEYPAIR + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=False) +def test_import_database_skips_embedded_credential_check_when_flag_off( + mocker: MockerFixture, session: Session +) -> None: + """ + With the fallback off the credential is inert, so import doesn't judge it -- + the same as the dialog and the API. + """ + from superset.commands.database.importers.v1.utils import import_database + + _import_snowflake_with_stored_keypair(mocker) + + half_filled = {"username": "EMBED_SVC", "auth_method": "keypair"} + database = import_database(_snowflake_config(half_filled), overwrite=True) + + stored = json.loads(database.encrypted_extra)["embedded_credentials"] + assert stored == half_filled + + +def test_import_database_checks_masks_with_the_incoming_engine( + mocker: MockerFixture, session: Session +) -> None: + """ + An overwrite moving to another engine is checked against that engine's paths. + + The stored engine's paths would not list the new engine's secret, so its mask + would be stored as the credential. + """ + from superset import security_manager + from superset.commands.database.importers.v1.utils import import_database + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + + config = { + "database_name": "moved_connection", + "sqlalchemy_uri": "postgresql://user@host/db", + "uuid": "7d2c1ee7-1d83-4f57-9a8e-1a2b3c4d5e6f", + "version": "1.0.0", + "extra": {}, + } + existing = import_database(copy.deepcopy(config)) + db.session.flush() + + moved = { + **copy.deepcopy(config), + "sqlalchemy_uri": "snowflake://user:new-pw@account/db", + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": {"username": "u", "password": PASSWORD_MASK}} + ), + } + with pytest.raises(ImportFailedError, match=r"\$\.embedded_credentials\.password"): + import_database(moved, overwrite=True) + + assert existing.encrypted_extra is None + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=True) +def test_import_database_refuses_incomplete_credential_in_plain_encrypted_extra( + mocker: MockerFixture, session: Session +) -> None: + """The completeness check also covers a file with only ``encrypted_extra``.""" + from superset.commands.database.importers.v1.utils import import_database + + existing = _import_snowflake_with_stored_keypair(mocker) + + config = _snowflake_config(EMBEDDED_KEYPAIR) + del config["masked_encrypted_extra"] + config["encrypted_extra"] = json.dumps({"embedded_credentials": {"username": "u"}}) + with pytest.raises(ImportFailedError, match=r"\$\.embedded_credentials\.password"): + import_database(config, overwrite=True) + + stored = json.loads(existing.encrypted_extra)["embedded_credentials"] + assert stored == EMBEDDED_KEYPAIR + + +def test_import_database_refuses_a_mask_in_plain_encrypted_extra( + mocker: MockerFixture, session: Session +) -> None: + """ + A connection already storing a mask exports it as plain `encrypted_extra`, + which the schema does not check, so it must not be stored as the secret. + """ + from superset import security_manager + from superset.commands.database.importers.v1.utils import import_database + from superset.models.core import Database + from tests.integration_tests.fixtures.importexport import ( + database_config_with_masked_encrypted_extra, + ) + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + + config = copy.deepcopy(database_config_with_masked_encrypted_extra) + del config["masked_encrypted_extra"] + config["encrypted_extra"] = json.dumps( + {"credentials_info": {"private_key": PASSWORD_MASK}} + ) + + with pytest.raises(ImportFailedError) as excinfo: + import_database(config) + assert "$.credentials_info.private_key" in str(excinfo.value) + + +@with_feature_flags(EMBEDDED_SUPERSET=True, EMBEDDED_CREDENTIAL_FALLBACK=True) +def test_import_database_does_not_count_a_mask_as_an_embedded_password( + mocker: MockerFixture, session: Session +) -> None: + """The completeness check would take the mask for a present password.""" + from superset.commands.database.importers.v1.utils import import_database + + existing = _import_snowflake_with_stored_keypair(mocker) + + config = _snowflake_config(EMBEDDED_KEYPAIR) + del config["masked_encrypted_extra"] + config["encrypted_extra"] = json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "client-secret"}, + "embedded_credentials": {"username": "svc", "password": PASSWORD_MASK}, + } + ) + with pytest.raises(ImportFailedError, match=r"\$\.embedded_credentials\.password"): + import_database(config, overwrite=True) + + stored = json.loads(existing.encrypted_extra)["embedded_credentials"] + assert stored == EMBEDDED_KEYPAIR diff --git a/tests/unit_tests/databases/schema_tests.py b/tests/unit_tests/databases/schema_tests.py index 876bc56971c9..81e4ceecd866 100644 --- a/tests/unit_tests/databases/schema_tests.py +++ b/tests/unit_tests/databases/schema_tests.py @@ -17,13 +17,15 @@ # pylint: disable=import-outside-toplevel, invalid-name, unused-argument, redefined-outer-name -from typing import Any, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING import pytest from marshmallow import fields, Schema, ValidationError from pytest_mock import MockerFixture +from superset.constants import PASSWORD_MASK from superset.utils import json +from tests.unit_tests.conftest import with_feature_flags if TYPE_CHECKING: from superset.databases.schemas import DatabaseParametersSchemaMixin @@ -635,6 +637,103 @@ def test_encrypted_extra_validator_rejects_non_dict_top_level_value( assert "must be a mapping" in str(exc_info.value) +@pytest.mark.parametrize( + "credentials", + [ + {"username": "embedded_svc"}, + {"password": "pw"}, + {"username": "embedded_svc", "password": ""}, + {"username": " ", "password": "pw"}, + {"username": " \t ", "auth_method": "keypair", "privatekey_body": "PEM"}, + {"username": "embedded_svc", "auth_method": "keypair"}, + {"auth_method": "keypair", "privatekey_body": "-----BEGIN-----"}, + {"username": "embedded_svc", "auth_method": "keypair", "privatekey_body": " "}, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_masked_encrypted_extra_rejects_a_half_filled_embedded_credential( + credentials: dict[str, str], +) -> None: + """ + Test that a direct API caller cannot store an incomplete guest credential. + + `Database.get_embedded_fallback_credentials` ignores one, so storing it leaves + a security-relevant setting that reads as configured and never applies. The + connection dialog is blocked by the validate command; this closes the same + hole for a POST/PUT that never goes near the dialog. Completeness is whatever + `superset.databases.utils.validated_embedded_credentials` accepts, for both + auth methods. + """ + from superset.databases.schemas import DatabasePostSchema + + schema = DatabasePostSchema() + payload = { + "database_name": "test_db", + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": credentials}, + ), + } + with pytest.raises(ValidationError) as exc_info: + schema.load(payload) + assert "Embedded guest credentials are invalid or incomplete" in str(exc_info.value) + + +@pytest.mark.parametrize( + "encrypted_extra", + [ + {"embedded_credentials": {"username": "embedded_svc", "password": "pw"}}, + { + "embedded_credentials": { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": "-----BEGIN PRIVATE KEY-----", + } + }, + {"embedded_credentials": {}}, + {"oauth2_client_info": {"id": "client-id"}}, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_masked_encrypted_extra_accepts_complete_or_absent_credentials( + encrypted_extra: dict[str, Any], +) -> None: + """ + Test that complete, empty and absent credentials all load. + """ + from superset.databases.schemas import DatabasePostSchema + + DatabasePostSchema().load( + { + "database_name": "test_db", + "masked_encrypted_extra": json.dumps(encrypted_extra), + } + ) + + +def test_import_accepts_a_half_filled_embedded_credential() -> None: + """ + Test that the import schema is deliberately not tightened. + + The check lives on `DatabaseParametersSchemaMixin` rather than on + `encrypted_extra_validator`, which also backs the import schemas: rejecting + this in the schema would refuse existing exports whatever the feature flags. + `import_database` refuses it only while the embedded fallback is on. + """ + from superset.databases.schemas import ImportV1DatabaseSchema + + ImportV1DatabaseSchema().load( + { + "database_name": "test_db", + "sqlalchemy_uri": "snowflake://user:pw@account/db", + "uuid": "e4b5b8a1-9c6f-4a2d-8f3e-1a2b3c4d5e6f", + "version": "1.0.0", + "encrypted_extra": json.dumps( + {"embedded_credentials": {"username": "embedded_svc"}}, + ), + } + ) + + def test_cache_timeout_rejects_values_below_minus_one() -> None: """ Test that cache_timeout rejects values less than -1. @@ -708,7 +807,15 @@ def test_import_schema_allows_masked_fields_for_existing_db( from superset.databases.schemas import ImportV1DatabaseSchema mock_session = mocker.patch("superset.databases.schemas.db.session") + from superset.db_engine_specs.bigquery import BigQueryEngineSpec + mock_existing_db = mocker.MagicMock() + mock_existing_db.db_engine_spec = BigQueryEngineSpec + mock_existing_db.encrypted_extra = json.dumps( + {"credentials_info": {"private_key": "stored-key"}} + ) + mock_existing_db.sqlalchemy_uri = "bigquery://test/" + mock_existing_db.extra = "{}" mock_session = mocker.patch("superset.databases.schemas.db.session") mock_session.query.return_value.filter_by.return_value.first.return_value = ( mock_existing_db @@ -729,6 +836,74 @@ def test_import_schema_allows_masked_fields_for_existing_db( schema.load(config) +def _existing_bigquery_db(mocker: MockerFixture, private_key: str) -> None: + """Make the import schema find an existing BigQuery connection.""" + from superset.db_engine_specs.bigquery import BigQueryEngineSpec + + existing = mocker.MagicMock() + existing.db_engine_spec = BigQueryEngineSpec + existing.encrypted_extra = json.dumps( + {"credentials_info": {"private_key": private_key}} + ) + existing.sqlalchemy_uri = "bigquery://test/" + existing.extra = "{}" + mock_session = mocker.patch("superset.databases.schemas.db.session") + mock_session.query.return_value.filter_by.return_value.first.return_value = existing + + +def _masked_bigquery_import(sqlalchemy_uri: str = "bigquery://test/") -> dict[str, Any]: + return { + "database_name": "test_db", + "sqlalchemy_uri": sqlalchemy_uri, + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "masked_encrypted_extra": json.dumps( + {"credentials_info": {"private_key": PASSWORD_MASK}} + ), + "extra": {}, + "version": "1.0.0", + } + + +def test_import_schema_asks_for_a_secret_stored_as_the_mask( + mock_bq_engine: None, + mocker: MockerFixture, +) -> None: + """ + An overwrite import asks for a secret whose stored value is the mask itself. + + Releases before this one saved the mask when an overwrite import had nothing + to reveal. Revealing it hands the mask back, so without asking here the + import reaches the ``import_database`` backstop and fails with a 500. + """ + from superset.databases.schemas import ImportV1DatabaseSchema + + _existing_bigquery_db(mocker, private_key=PASSWORD_MASK) + + with pytest.raises(ValidationError) as excinfo: + ImportV1DatabaseSchema().load(_masked_bigquery_import()) + assert "$.credentials_info.private_key" in str(excinfo.value) + + +def test_import_schema_asks_for_secrets_when_the_connection_moves( + mock_bq_engine: None, + mocker: MockerFixture, +) -> None: + """ + An overwrite import that changes where the connection points asks for every + masked secret: ``import_database`` never reveals stored secrets onto a new + destination, so it would otherwise store the mask and fail with a 500. + """ + from superset.databases.schemas import ImportV1DatabaseSchema + + _existing_bigquery_db(mocker, private_key="stored-key") + + with pytest.raises(ValidationError) as excinfo: + ImportV1DatabaseSchema().load( + _masked_bigquery_import(sqlalchemy_uri="bigquery://other-project/") + ) + assert "$.credentials_info.private_key" in str(excinfo.value) + + def test_ssh_tunnel_server_address_rejects_non_hostnames() -> None: """server_address must look like a hostname/IP, not a URL or arbitrary text.""" from superset.databases.schemas import DatabaseSSHTunnel @@ -802,6 +977,393 @@ def test_validate_parameters_schema_accepts_server_host_key() -> None: ) +@pytest.mark.parametrize( + "disabled_flag", ["EMBEDDED_CREDENTIAL_FALLBACK", "EMBEDDED_SUPERSET"] +) +@pytest.mark.parametrize( + "schema_name", + ["DatabasePostSchema", "DatabasePutSchema", "DatabaseTestConnectionSchema"], +) +def test_disabled_embedded_credentials_survive_unrelated_api_edits( + mocker: MockerFixture, disabled_flag: str, schema_name: str +) -> None: + """Save and connection-test schemas preserve partial imports while hidden.""" + from superset.databases import schemas + + mocker.patch( + "superset.extensions.feature_flag_manager.get_feature_flags", + return_value={ + "EMBEDDED_CREDENTIAL_FALLBACK": True, + "EMBEDDED_SUPERSET": True, + disabled_flag: False, + }, + ) + stored = json.dumps({"embedded_credentials": {"username": "embedded_svc"}}) + loaded = getattr(schemas, schema_name)().load( + { + "database_name": "renamed", + "sqlalchemy_uri": "snowflake://user:pw@account/db", + "masked_encrypted_extra": stored, + } + ) + assert loaded["masked_encrypted_extra"] == stored + + +@pytest.mark.parametrize( + "schema_name", + ["DatabasePostSchema", "DatabasePutSchema", "DatabaseTestConnectionSchema"], +) +@pytest.mark.parametrize("valid", [True, False]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_api_schemas_use_the_engine_credential_validator( + mocker: MockerFixture, schema_name: str, valid: bool +) -> None: + """Save and test endpoints accept the same credential shapes as execution.""" + from superset.databases import schemas + from superset.db_engine_specs.sqlite import SqliteEngineSpec + from superset.models.core import Database + + class TokenEngineSpec(SqliteEngineSpec): + @classmethod + def validate_embedded_credentials( + cls, credentials: Any + ) -> dict[str, str] | None: + if isinstance(credentials, dict) and credentials.get("token"): + return credentials + return None + + mocker.patch.object(Database, "get_db_engine_spec", return_value=TokenEngineSpec) + credentials = ( + {"token": "engine-token"} if valid else {"username": "svc", "password": "pw"} + ) + payload = { + "database_name": "test_db", + "sqlalchemy_uri": "sqlite://", + "masked_encrypted_extra": json.dumps({"embedded_credentials": credentials}), + } + schema = getattr(schemas, schema_name)() + if valid: + assert ( + schema.load(payload)["masked_encrypted_extra"] + == payload["masked_encrypted_extra"] + ) + else: + with pytest.raises(ValidationError) as excinfo: + schema.load(payload) + assert "masked_encrypted_extra" in excinfo.value.messages + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_partial_update_uses_the_stored_engine_credential_validator( + mocker: MockerFixture, +) -> None: + """An update without a URI still validates with the connection's engine.""" + from superset.databases.schemas import DatabasePutSchema + from superset.db_engine_specs.sqlite import SqliteEngineSpec + from superset.models.core import Database + + database = Database(sqlalchemy_uri="sqlite://") + mocker.patch("superset.databases.schemas.db.session.get", return_value=database) + validator = mocker.patch.object( + SqliteEngineSpec, "validate_embedded_credentials", return_value={"token": "t"} + ) + payload = { + "masked_encrypted_extra": json.dumps({"embedded_credentials": {"token": "t"}}) + } + DatabasePutSchema(database_id=1).load(payload) + validator.assert_called_once_with({"token": "t"}) + + +@pytest.mark.parametrize( + "credentials", + [ + "not json", + "[]", + "42", + [], + False, + 0, + '{"username": "embedded_svc", "password": "pw"}', + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_api_rejects_malformed_embedded_credentials(credentials: Any) -> None: + """Valid outer JSON must not hide a malformed credential value.""" + from superset.databases.schemas import DatabasePostSchema + + with pytest.raises(ValidationError) as excinfo: + DatabasePostSchema().load( + { + "database_name": "test_db", + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": credentials} + ), + } + ) + assert "masked_encrypted_extra" in excinfo.value.messages + + +_CLIENT = { + "id": "client", + "secret": "secret", + "scope": "refresh_token", + "authorization_request_uri": "https://oauth.example/authorize", + "token_request_uri": "https://oauth.example/token", +} +_CREDENTIALS = {"username": "embedded_svc", "password": "embedded_pw"} + + +@pytest.mark.parametrize( + "impersonate_user,client,global_client,credentials,expect_error", + [ + # sc-126624 case 4: impersonation off -> every viewer on the base account. + (False, _CLIENT, False, _CREDENTIALS, True), + # sc-126624 case 1: no OAuth2 client -> the credential never applies. + (True, None, False, _CREDENTIALS, True), + (True, _CLIENT, False, _CREDENTIALS, False), + # A deployment-wide client counts as OAuth2 on the connection. + (True, None, True, _CREDENTIALS, False), + # A cleared credential is "no credential", wherever it is saved. + (False, None, False, {"username": "", "password": ""}, False), + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_credentials_need_oauth2_and_impersonation( + mocker: MockerFixture, + impersonate_user: bool, + client: Optional[dict[str, str]], + global_client: bool, + credentials: dict[str, str], + expect_error: bool, +) -> None: + """A credential that could never apply is refused instead of saved.""" + from flask import current_app + + from superset.databases.schemas import DatabasePostSchema + from superset.db_engine_specs.sqlite import SqliteEngineSpec + + mocker.patch.object(SqliteEngineSpec, "supports_embedded_credential_fallback", True) + mocker.patch.object(SqliteEngineSpec, "supports_oauth2", True) + mocker.patch.dict( + current_app.config, + {"DATABASE_OAUTH2_CLIENTS": {"SQLite": _CLIENT} if global_client else {}}, + ) + extra: dict[str, Any] = {"embedded_credentials": credentials} + if client: + extra["oauth2_client_info"] = client + payload = { + "database_name": "test_db", + "sqlalchemy_uri": "sqlite://", + "impersonate_user": impersonate_user, + "masked_encrypted_extra": json.dumps(extra), + } + + if expect_error: + with pytest.raises(ValidationError) as excinfo: + DatabasePostSchema().load(payload) + assert "Impersonate logged in user" in str(excinfo.value.messages) + else: + DatabasePostSchema().load(payload) + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_credentials_requirement_skips_engines_without_the_fallback() -> None: + """On an engine that has not opted in the credential is inert; not refused.""" + from superset.databases.schemas import DatabasePostSchema + + DatabasePostSchema().load( + { + "database_name": "test_db", + "sqlalchemy_uri": "sqlite://", + "impersonate_user": False, + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": _CREDENTIALS} + ), + } + ) + + +@pytest.mark.parametrize("stored_impersonate_user", [True, False]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_credentials_requirement_uses_stored_impersonation_on_update( + mocker: MockerFixture, stored_impersonate_user: bool +) -> None: + """An update that omits impersonate_user is judged by the stored setting.""" + from superset.databases.schemas import DatabasePutSchema + from superset.db_engine_specs.sqlite import SqliteEngineSpec + from superset.models.core import Database + + mocker.patch.object(SqliteEngineSpec, "supports_embedded_credential_fallback", True) + stored = Database( + sqlalchemy_uri="sqlite://", impersonate_user=stored_impersonate_user + ) + mocker.patch("superset.databases.schemas.db.session.get", return_value=stored) + payload = { + "masked_encrypted_extra": json.dumps( + {"embedded_credentials": _CREDENTIALS, "oauth2_client_info": _CLIENT} + ) + } + + if stored_impersonate_user: + DatabasePutSchema(database_id=1).load(payload) + else: + with pytest.raises(ValidationError): + DatabasePutSchema(database_id=1).load(payload) + + +@pytest.mark.parametrize( + "stored_encrypted_extra", + [None, json.dumps({"oauth2_client_info": {"id": "client"}})], +) +def test_import_schema_asks_for_masks_an_existing_db_cannot_reveal( + mocker: MockerFixture, stored_encrypted_extra: Optional[str] +) -> None: + """ + Overwriting a connection asks for secrets it has no stored value for. + + A connection promoted by a dashboard bundle exists without its OAuth2 client + or embedded credential. Importing its database export over it used to store + the mask itself as the secret (QA OEF-SECRET-04). + """ + from superset.databases.schemas import ImportV1DatabaseSchema + from superset.db_engine_specs.sqlite import SqliteEngineSpec + + existing = mocker.MagicMock() + existing.db_engine_spec = SqliteEngineSpec + existing.encrypted_extra = stored_encrypted_extra + mock_session = mocker.patch("superset.databases.schemas.db.session") + mock_session.query.return_value.filter_by.return_value.first.return_value = existing + + with pytest.raises(ValidationError) as excinfo: + ImportV1DatabaseSchema().load( + { + "database_name": "promoted", + "sqlalchemy_uri": "sqlite://", + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "masked_encrypted_extra": json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "XXXXXXXXXX"}, + "embedded_credentials": { + "username": "svc", + "password": "XXXXXXXXXX", + }, + } + ), + "extra": {}, + "version": "1.0.0", + } + ) + + messages = str(excinfo.value.messages) + assert "$.oauth2_client_info.secret" in messages + assert "$.embedded_credentials.password" in messages + + +@pytest.mark.parametrize( + "stored_credentials,refused", + [ + ({"username": "embedded_svc"}, True), + ({"username": "embedded_svc", "password": PASSWORD_MASK}, True), + ({"username": "embedded_svc", "password": "stored"}, False), + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_masked_embedded_secret_needs_a_stored_value( + mocker: MockerFixture, stored_credentials: dict[str, str], refused: bool +) -> None: + """A mask with nothing stored behind it would be saved as the credential.""" + from superset.databases.schemas import DatabasePutSchema + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + from superset.models.core import Database + + mocker.patch.object(Database, "db_engine_spec", new=SnowflakeEngineSpec) + database = Database( + sqlalchemy_uri="snowflake://svc@account/db", + impersonate_user=True, + encrypted_extra=json.dumps({"embedded_credentials": stored_credentials}), + ) + mocker.patch("superset.databases.schemas.db.session.get", return_value=database) + payload = { + "masked_encrypted_extra": json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "secret"}, + "embedded_credentials": { + "username": "embedded_svc", + "password": PASSWORD_MASK, + }, + } + ), + } + + if not refused: + DatabasePutSchema(database_id=1).load(payload) + return + with pytest.raises(ValidationError, match="invalid or incomplete"): + DatabasePutSchema(database_id=1).load(payload) + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_test_connection_leaves_masks_to_the_command() -> None: + """ + The schema has no `database_id` to reveal a mask with, and saves nothing, so + testing a saved connection must not refuse the mask the dialog sends back. + """ + from superset.databases.schemas import ( + DatabasePostSchema, + DatabaseTestConnectionSchema, + ) + + payload = { + "database_name": "test_db", + "sqlalchemy_uri": "snowflake://svc@account/db", + "impersonate_user": True, + "masked_encrypted_extra": json.dumps( + { + "oauth2_client_info": _CLIENT, + "embedded_credentials": { + "username": "embedded_svc", + "password": PASSWORD_MASK, + }, + } + ), + } + + DatabaseTestConnectionSchema().load(payload) + with pytest.raises(ValidationError, match="invalid or incomplete"): + DatabasePostSchema().load(payload) + + +def test_import_schema_checks_masks_with_the_incoming_engine( + mock_bq_engine: None, + mocker: MockerFixture, +) -> None: + """An overwrite moving to another engine is checked against that engine's paths.""" + from superset.databases.schemas import ImportV1DatabaseSchema + from superset.db_engine_specs.sqlite import SqliteEngineSpec + + existing = mocker.MagicMock() + existing.db_engine_spec = SqliteEngineSpec + existing.encrypted_extra = None + existing.sqlalchemy_uri = "sqlite://" + existing.extra = "{}" + mock_session = mocker.patch("superset.databases.schemas.db.session") + mock_session.query.return_value.filter_by.return_value.first.return_value = existing + + with pytest.raises(ValidationError, match="credentials_info.private_key"): + ImportV1DatabaseSchema().load( + { + "database_name": "test_db", + "sqlalchemy_uri": "bigquery://test/", + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "masked_encrypted_extra": json.dumps( + {"credentials_info": {"private_key": PASSWORD_MASK}} + ), + "extra": {}, + "version": "1.0.0", + } + ) + + @pytest.mark.parametrize("schema_name", ["DatabasePostSchema", "DatabasePutSchema"]) def test_databricks_form_clear_catalog_removes_legacy_override( schema_name: str, diff --git a/tests/unit_tests/datasets/commands/bundle_secrets_test.py b/tests/unit_tests/datasets/commands/bundle_secrets_test.py new file mode 100644 index 000000000000..0b0d56edcf16 --- /dev/null +++ b/tests/unit_tests/datasets/commands/bundle_secrets_test.py @@ -0,0 +1,381 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Bundle exports carry a connection's Secure Extra, masked, and imports ask for it. + +A dashboard, chart or dataset bundle used to write the database YAML without +``encrypted_extra``, so promoting a dashboard dropped the connection's OAuth2 +client and embedded viewer credential with no warning (sc-126855, QA +OEF-SECRET-14). +""" + +from typing import Any + +import pytest +import yaml +from pytest_mock import MockerFixture +from sqlalchemy.orm.session import Session + +from superset import db +from superset.constants import PASSWORD_MASK +from superset.utils import json + +SECRETS = { + "oauth2_client_info": { + "id": "client-id", + "secret": "client-secret", + "scope": "refresh_token", + "authorization_request_uri": "https://account.example/oauth/authorize", + "token_request_uri": "https://account.example/oauth/token-request", + }, + "embedded_credentials": {"username": "embedded_svc", "password": "embedded-pw"}, +} + + +@pytest.fixture +def snowflake_like_paths(mocker: MockerFixture) -> None: + """Mask the specific secret paths, as the Snowflake engine spec does.""" + from superset.db_engine_specs.sqlite import SqliteEngineSpec + + mocker.patch.object( + SqliteEngineSpec, + "encrypted_extra_sensitive_fields", + {"$.oauth2_client_info.secret": "OAuth2 Client Secret"}, + ) + + +def _export_bundle(secure_extra: dict[str, Any] | None = None) -> dict[str, str]: + """Export a dataset on a connection carrying ``secure_extra``.""" + from superset.commands.dataset.export import ExportDatasetsCommand + from superset.connectors.sqla.models import SqlaTable + from superset.models.core import Database + + SqlaTable.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + database = Database( + database_name="promoted", + sqlalchemy_uri="sqlite://", + impersonate_user=True, + encrypted_extra=json.dumps(SECRETS if secure_extra is None else secure_extra), + ) + db.session.add(database) + db.session.flush() + dataset = SqlaTable(table_name="identity", database=database, columns=[]) + db.session.add(dataset) + db.session.flush() + + bundle = { + name: content() for name, content in ExportDatasetsCommand([dataset.id]).run() + } + + db.session.delete(dataset) + db.session.delete(database) + db.session.flush() + return bundle + + +def _database_file(bundle: dict[str, str]) -> str: + return next(name for name in bundle if name.startswith("databases/")) + + +@pytest.mark.usefixtures("snowflake_like_paths") +def test_bundle_export_carries_secure_extra_masked( + mocker: MockerFixture, session: Session +) -> None: + """The bundle's database YAML keeps both blocks, with no secret in clear.""" + from superset import security_manager + + mocker.patch.object(security_manager, "can_access", return_value=True) + bundle = _export_bundle() + + config = yaml.safe_load(bundle[_database_file(bundle)]) + + assert "encrypted_extra" not in config + masked = json.loads(config["masked_encrypted_extra"]) + assert masked["oauth2_client_info"]["secret"] == PASSWORD_MASK + assert masked["oauth2_client_info"]["id"] == "client-id" + assert masked["embedded_credentials"] == { + "username": "embedded_svc", + "password": PASSWORD_MASK, + } + for content in bundle.values(): + assert "client-secret" not in content + assert "embedded-pw" not in content + + +@pytest.mark.usefixtures("snowflake_like_paths") +def test_bundle_import_asks_for_secrets_and_keeps_them( + mocker: MockerFixture, session: Session +) -> None: + """ + Importing where the connection doesn't exist asks for both secrets, then + stores what was supplied -- never the mask. + """ + from superset import security_manager + from superset.commands.dataset.importers.v1 import ImportDatasetsCommand + from superset.commands.exceptions import CommandInvalidError + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + bundle = _export_bundle() + database_file = _database_file(bundle) + + with pytest.raises(CommandInvalidError) as excinfo: + ImportDatasetsCommand(bundle).run() + messages = str(excinfo.value.normalized_messages()) + assert "$.oauth2_client_info.secret" in messages + assert "$.embedded_credentials.password" in messages + + secrets: dict[str, Any] = { + database_file: { + "$.oauth2_client_info.secret": "client-secret", + "$.embedded_credentials.password": "embedded-pw", + } + } + ImportDatasetsCommand(bundle, encrypted_extra_secrets=secrets).run() + + imported = db.session.query(Database).filter_by(database_name="promoted").one() + assert json.loads(imported.encrypted_extra) == SECRETS + assert imported.impersonate_user is True + + +@pytest.mark.usefixtures("snowflake_like_paths") +def test_bundle_import_reuses_an_existing_connection_without_asking( + mocker: MockerFixture, session: Session +) -> None: + """ + A bundle whose connection already exists reuses it as is: its masked secrets + are never applied, so the import must not stall asking for them. + """ + from superset import security_manager + from superset.commands.dataset.importers.v1 import ImportDatasetsCommand + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + bundle = _export_bundle() + config = yaml.safe_load(bundle[_database_file(bundle)]) + existing = Database( + database_name="promoted", + sqlalchemy_uri="sqlite://", + uuid=config["uuid"], + encrypted_extra=None, + ) + db.session.add(existing) + db.session.flush() + + ImportDatasetsCommand(bundle).run() + + assert existing.encrypted_extra is None + + +CERTIFICATES = { + "certificates": { + "client-cert.pem": "-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----", + "client-key.pem": "-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----", + } +} + + +@pytest.mark.usefixtures("snowflake_like_paths") +def test_bundle_export_never_carries_the_rest_of_secure_extra( + mocker: MockerFixture, session: Session +) -> None: + """ + A bundle carries only the OAuth2 client and embedded credential blocks. + + Anyone who can export a dashboard or dataset gets the bundle, and an engine's + own Secure Extra -- a Postgres client key -- is not registered as sensitive, + so masking alone would leave it in clear. + """ + from superset import security_manager + + mocker.patch.object(security_manager, "can_access", return_value=True) + bundle = _export_bundle({**SECRETS, **CERTIFICATES}) + + config = yaml.safe_load(bundle[_database_file(bundle)]) + + assert "encrypted_extra" not in config + carried = json.loads(config["masked_encrypted_extra"]) + assert set(carried) == {"oauth2_client_info", "embedded_credentials"} + for content in bundle.values(): + assert "client-key.pem" not in content + assert "PRIVATE KEY" not in content + + +@pytest.mark.usefixtures("snowflake_like_paths") +def test_bundle_export_carries_no_secure_extra_without_the_blocks( + mocker: MockerFixture, session: Session +) -> None: + """A connection with neither block exports no Secure Extra at all.""" + from superset import security_manager + + mocker.patch.object(security_manager, "can_access", return_value=True) + bundle = _export_bundle(CERTIFICATES) + + config = yaml.safe_load(bundle[_database_file(bundle)]) + + assert "encrypted_extra" not in config + assert "masked_encrypted_extra" not in config + for content in bundle.values(): + assert "PRIVATE KEY" not in content + + +def test_bundle_export_skips_blocks_masked_whole( + mocker: MockerFixture, session: Session +) -> None: + """ + An engine that masks Secure Extra with ``$.*`` (Databricks, the base spec) + turns each block into a bare mask string. The bundle carries no such block: + an import would ask for ``$.oauth2_client_info`` and store the answer as a + string, which every query then fails to read as an OAuth2 client. + """ + from superset import security_manager + from superset.commands.dataset.importers.v1 import ImportDatasetsCommand + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + bundle = _export_bundle() + + config = yaml.safe_load(bundle[_database_file(bundle)]) + + assert "masked_encrypted_extra" not in config + for content in bundle.values(): + assert PASSWORD_MASK not in content + + ImportDatasetsCommand(bundle).run() + + imported = db.session.query(Database).filter_by(database_name="promoted").one() + assert imported.encrypted_extra is None + + +def test_dashboard_import_overwriting_all_asks_for_unrevealable_secrets( + mocker: MockerFixture, session: Session +) -> None: + """ + A dashboard import with ``overwrite`` and ``overwrite_all`` overwrites its + connection, so it asks for a masked secret the stored connection has no value + for -- the structured prompt, not the ``import_database`` backstop's 500. + """ + import copy + + from superset import security_manager + from superset.commands.dashboard.importers.v1 import ImportDashboardsCommand + from superset.commands.exceptions import CommandInvalidError + from superset.models.core import Database + from tests.integration_tests.fixtures.importexport import ( + chart_config, + dashboard_config, + dashboard_metadata_config, + database_config, + dataset_config, + ) + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + db.session.add( + Database( + database_name="imported_database", + sqlalchemy_uri=database_config["sqlalchemy_uri"], + uuid=database_config["uuid"], + encrypted_extra=None, + ) + ) + db.session.flush() + + database = copy.deepcopy(database_config) + database["masked_encrypted_extra"] = json.dumps( + {"oauth2_client_info": {"id": "client-id", "secret": PASSWORD_MASK}} + ) + contents = { + "metadata.yaml": yaml.safe_dump(dashboard_metadata_config), + "databases/imported_database.yaml": yaml.safe_dump(database), + "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config), + "charts/imported_chart.yaml": yaml.safe_dump(chart_config), + "dashboards/imported_dashboard.yaml": yaml.safe_dump(dashboard_config), + } + + with pytest.raises(CommandInvalidError) as excinfo: + ImportDashboardsCommand(contents, overwrite=True, overwrite_all=True).run() + assert "$.oauth2_client_info.secret" in str(excinfo.value.normalized_messages()) + + +@pytest.mark.usefixtures("snowflake_like_paths") +def test_dashboard_import_overwriting_all_keeps_what_the_bundle_does_not_carry( + mocker: MockerFixture, session: Session +) -> None: + """Overwriting a connection from a dashboard bundle keeps its other settings.""" + import copy + + from superset import security_manager + from superset.commands.dashboard.importers.v1 import ImportDashboardsCommand + from superset.models.core import Database + from tests.integration_tests.fixtures.importexport import ( + chart_config, + dashboard_config, + dashboard_metadata_config, + database_config, + dataset_config, + ) + + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + mocker.patch("superset.commands.chart.importers.v1.utils.security_manager") + mocker.patch("superset.commands.dashboard.importers.v1.utils.security_manager") + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + stored = {**SECRETS, "auth_params": {"privatekey_pass": "kept"}} + db.session.add( + Database( + database_name="imported_database", + sqlalchemy_uri=database_config["sqlalchemy_uri"], + uuid=database_config["uuid"], + encrypted_extra=json.dumps(stored), + ) + ) + db.session.flush() + + database = copy.deepcopy(database_config) + database["masked_encrypted_extra"] = json.dumps( + { + "oauth2_client_info": { + **SECRETS["oauth2_client_info"], + "secret": PASSWORD_MASK, + } + } + ) + + def _exported(config: dict[str, Any]) -> dict[str, Any]: + # Other tests' imports leave an `id` on these shared fixtures, which an + # exported file never has. + return {key: value for key, value in config.items() if key != "id"} + + contents = { + "metadata.yaml": yaml.safe_dump(dashboard_metadata_config), + "databases/imported_database.yaml": yaml.safe_dump(database), + "datasets/imported_dataset.yaml": yaml.safe_dump(_exported(dataset_config)), + "charts/imported_chart.yaml": yaml.safe_dump(_exported(chart_config)), + "dashboards/imported_dashboard.yaml": yaml.safe_dump( + _exported(dashboard_config) + ), + } + + ImportDashboardsCommand(contents, overwrite=True, overwrite_all=True).run() + + imported = db.session.query(Database).filter_by(uuid=database_config["uuid"]).one() + assert json.loads(imported.encrypted_extra) == stored diff --git a/tests/unit_tests/db_engine_specs/test_base.py b/tests/unit_tests/db_engine_specs/test_base.py index c51f12288985..4e77456108e4 100644 --- a/tests/unit_tests/db_engine_specs/test_base.py +++ b/tests/unit_tests/db_engine_specs/test_base.py @@ -53,6 +53,7 @@ OAuth2RedirectError, SupersetGenericDBErrorException, ) +from superset.security.guest_token import GuestToken, GuestUser from superset.sql.parse import Table from superset.superset_typing import ( OAuth2ClientConfig, @@ -63,6 +64,7 @@ from superset.utils.core import FilterOperator, GenericDataType from superset.utils.oauth2 import decode_oauth2_state from tests.conftest import with_config +from tests.unit_tests.conftest import with_feature_flags from tests.unit_tests.db_engine_specs.utils import assert_column_spec @@ -543,7 +545,48 @@ class DictFieldsSpec(BaseEngineSpec): if masked_encrypted_extra: encrypted_extra_sensitive_fields = masked_encrypted_extra - assert DictFieldsSpec.encrypted_extra_sensitive_field_paths() == expected_result + assert DictFieldsSpec.encrypted_extra_sensitive_field_paths() == ( + expected_result | ALWAYS_MASKED_PATHS + ) + + +# Masked for every engine, whatever it declares: `Database` reads both blocks from +# any connection's `encrypted_extra`. +ALWAYS_MASKED_PATHS = { + "$.oauth2_client_info.secret", + "$.embedded_credentials.password", + "$.embedded_credentials.privatekey_body", + "$.embedded_credentials.privatekey_pass", +} + + +def test_secrets_are_masked_for_an_engine_that_declares_no_sensitive_fields() -> None: + """ + The OAuth2 client secret and embedded credential are masked regardless. + + An engine with no sensitive fields of its own, or a deployment override that + fails to load, must not return either in clear. + """ + + class NoSensitiveFieldsSpec(BaseEngineSpec): + encrypted_extra_sensitive_fields: set[str] = set() + + masked_text = NoSensitiveFieldsSpec.mask_encrypted_extra( + json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "s3cret"}, + "embedded_credentials": {"username": "svc", "password": "pw"}, + } + ) + ) + assert masked_text is not None + masked = json.loads(masked_text) + + assert masked["oauth2_client_info"] == {"id": "client", "secret": "XXXXXXXXXX"} + assert masked["embedded_credentials"] == { + "username": "svc", + "password": "XXXXXXXXXX", + } def test_impersonate_user_backwards_compatible(mocker: MockerFixture) -> None: @@ -2048,6 +2091,265 @@ def test_epoch_us_to_dttm(spec: type[BaseEngineSpec], expected: str) -> None: assert spec.epoch_us_to_dttm() == expected +def _guest_user(username: str = "guest_user") -> GuestUser: + """ + Build an embedded guest principal, the way the guest-token loader would. + """ + token: GuestToken = { + "user": {"username": username}, + "resources": [], + "rls_rules": [], + "iat": 0, + "exp": 1, + } + return GuestUser(token=token, roles=[]) + + +@with_feature_flags(EMBEDDED_SUPERSET=True) +def test_needs_oauth2_for_a_logged_in_user(mocker: MockerFixture) -> None: + """ + Test that a logged-in user hitting an OAuth2 error still starts the dance. + """ + g = mocker.patch("superset.db_engine_specs.base.g") + g.user = mocker.MagicMock(spec=["id", "username"]) + + assert BaseEngineSpec.needs_oauth2(OAuth2RedirectError("url", "tab", "redirect")) + + +@with_feature_flags(EMBEDDED_SUPERSET=True) +def test_needs_oauth2_is_false_for_an_embedded_guest(mocker: MockerFixture) -> None: + """ + Test that an embedded guest never triggers the OAuth2 dance. + + A guest authenticates with a guest token rather than a Superset account, so there + is no per-user token to resolve and no way for them to authorize one. Before this + guard, `start_oauth2_dance` read `g.user.id` -- which `GuestUser` does not define + -- and the viewer got an `AttributeError` and a 500 instead of the driver's error. + """ + g = mocker.patch("superset.db_engine_specs.base.g") + g.user = _guest_user() + + assert not BaseEngineSpec.needs_oauth2( + OAuth2RedirectError("url", "tab", "redirect") + ) + + +@with_feature_flags(EMBEDDED_SUPERSET=True) +def test_needs_oauth2_is_false_for_an_unrelated_exception( + mocker: MockerFixture, +) -> None: + """ + Test that a non-OAuth2 error is not treated as one, for either principal. + """ + g = mocker.patch("superset.db_engine_specs.base.g") + + g.user = mocker.MagicMock(spec=["id", "username"]) + assert not BaseEngineSpec.needs_oauth2(ValueError("not an OAuth2 problem")) + + g.user = _guest_user() + assert not BaseEngineSpec.needs_oauth2(ValueError("not an OAuth2 problem")) + + +def test_needs_oauth2_without_a_user(mocker: MockerFixture) -> None: + """ + Test that a request with no user on `g` does not start the dance. + """ + mocker.patch("superset.db_engine_specs.base.g", spec=[]) + + assert not BaseEngineSpec.needs_oauth2( + OAuth2RedirectError("url", "tab", "redirect") + ) + + +@with_config( + { + "SECRET_KEY": "test-secret-key", + "DATABASE_OAUTH2_JWT_ALGORITHM": "HS256", + } +) +def test_start_oauth2_dance_rejects_a_principal_without_an_id( + mocker: MockerFixture, +) -> None: + """ + Test that the dance fails legibly when the principal has no id. + + This is the backstop behind `needs_oauth2`: a caller that skips the guest check + should get an `OAuth2Error` naming the problem, not an `AttributeError` raised + while building the state dict. + """ + mocker.patch("superset.daos.key_value.KeyValueDAO") + mocker.patch("superset.db_engine_specs.base.db") + + g = mocker.patch("superset.db_engine_specs.base.g") + g.user = _guest_user() + + database = mocker.MagicMock() + database.id = 1 + + with pytest.raises(OAuth2Error) as excinfo: + BaseEngineSpec.start_oauth2_dance(database) + + assert "authenticated user with an id" in excinfo.value.error.extra["error"] + + +def test_apply_embedded_credentials_replaces_the_connection_identity( + mocker: MockerFixture, +) -> None: + """ + Test that the default implementation replaces, rather than merges, the identity. + + ``URL.set`` ignores ``password=None``, so merging would leave a credential that + carries no password -- a key pair, say -- paired with the connection's own + password. Everything else about the URL has to survive: a dropped query + parameter here is a connection that silently loses its warehouse or role. + """ + url = make_url( + "snowflake://svc_user:svc_pass@xy12345.us-east-2.aws:443/ANALYTICS_DB" + "?role=ANALYST&warehouse=COMPUTE_WH" + ) + engine_kwargs: dict[str, Any] = {"connect_args": {}} + + applied_url, applied_kwargs = BaseEngineSpec.apply_embedded_credentials( + mocker.MagicMock(), + {"username": "embedded_svc", "auth_method": "keypair"}, + url, + engine_kwargs, + ) + + assert applied_url.username == "embedded_svc" + assert applied_url.password is None + assert applied_url.host == "xy12345.us-east-2.aws" + assert applied_url.port == 443 + assert applied_url.database == "ANALYTICS_DB" + assert dict(applied_url.query) == {"role": "ANALYST", "warehouse": "COMPUTE_WH"} + # The base implementation has no opinion about engine kwargs. + assert applied_kwargs is engine_kwargs + + +def test_apply_embedded_credentials_sets_a_password_on_the_url( + mocker: MockerFixture, +) -> None: + """ + Test that a password credential lands on the URL, not in ``connect_args``. + + The per-process engine cache is keyed on the rendered URL and the engine kwargs. + Putting the credential on the URL is what keeps a guest's engine and a logged-in + user's engine on separate entries; moving it into ``connect_args`` would still be + in the key today, but the URL is where the rest of the stack expects a + connection's identity to be visible. + """ + url = make_url("snowflake://svc_user:svc_pass@xy12345/DB") + + applied_url, _ = BaseEngineSpec.apply_embedded_credentials( + mocker.MagicMock(), + {"username": "embedded_svc", "password": "embedded_pw"}, + url, + {}, + ) + + assert applied_url.username == "embedded_svc" + assert applied_url.password == "embedded_pw" # noqa: S105 + + +@pytest.mark.parametrize( + "credentials,expected", + [ + ( + {"username": "u", "password": "p"}, + {"username": "u", "password": "p"}, + ), + ( + {"username": "u", "auth_method": "keypair", "privatekey_body": "PEM"}, + { + "username": "u", + "auth_method": "keypair", + "privatekey_body": "PEM", + "privatekey_pass": "", + }, + ), + ({"username": " ", "password": "p"}, None), + ( + {"username": " \t ", "auth_method": "keypair", "privatekey_body": "PEM"}, + None, + ), + ( + {"username": " u ", "password": " p "}, + {"username": " u ", "password": " p "}, + ), + ({"username": "u"}, None), + ({"password": "p"}, None), + ({"username": "u", "auth_method": "keypair"}, None), + ({"username": "u", "auth_method": "unknown", "password": "p"}, None), + ("not a dict", None), + (None, None), + ], +) +def test_validate_embedded_credentials_default_rule( + credentials: Any, + expected: dict[str, str] | None, +) -> None: + """ + Test the canonical credential shape every engine inherits. + + A half-filled credential resolves to ``None`` rather than being half-applied, so + that a connection cannot end up authenticating with a username and no secret. + """ + assert BaseEngineSpec.validate_embedded_credentials(credentials) == expected + + +def test_get_embedded_credential_cache_key_default() -> None: + """ + Test that the default cache key is the configured identity. + + Not the requesting principal's: a ``GuestUser`` carries whatever username the + guest token claims, so keying on that would let whoever mints the token choose + which cache entry they read. + """ + assert ( + BaseEngineSpec.get_embedded_credential_cache_key( + {"username": "embedded_svc", "password": "embedded_pw"} + ) + == "embedded:embedded_svc" + ) + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_an_opting_in_engine_gets_the_credential_field_without_an_override() -> None: + """ + Test that opting a second engine in is a schema field, not a copied override. + + The ``x-encrypted-extra`` tagging and the feature-flag gating both live in the + shared ``parameters_json_schema``, so an engine opts in by declaring the field + and flipping ``supports_embedded_credential_fallback``. When each engine carried + its own copy of that method, opting in meant duplicating all of it -- and the + copies drifted. + """ + from marshmallow import fields as ma_fields, Schema as MarshmallowSchema + + from superset.databases.types import EncryptedString + from superset.db_engine_specs.postgres import PostgresEngineSpec + + class OptedInParametersSchema(MarshmallowSchema): + host = ma_fields.Str() + embedded_credentials = EncryptedString(required=False, allow_none=True) + + # Subclassing a spec that already mixes in `BasicParametersMixin`, which is how + # a dynamic-parameters engine reaches `parameters_json_schema`. The opt-in is + # the schema field plus the class attribute and nothing else. + class OptedInEngineSpec(PostgresEngineSpec): + engine = "optedin" + engine_name = "Opted In" + parameters_schema = OptedInParametersSchema() + supports_embedded_credential_fallback = True + + field = OptedInEngineSpec.parameters_json_schema()["properties"][ + "embedded_credentials" + ] + + assert field["x-encrypted-extra"] is True + assert field["default"] == {"engine_oauth2_client_configured": False} + + def test_expand_nested_columns_default_returns_input() -> None: """ By default the columns inferred from ``cursor.description`` are kept as is. @@ -2057,3 +2359,26 @@ def test_expand_nested_columns_default_returns_input() -> None: ] assert BaseEngineSpec.expand_nested_columns(object(), columns) is columns + + +def test_update_params_from_encrypted_extra_drops_the_embedded_credential( + mocker: MockerFixture, +) -> None: + """ + The fallback credential never becomes an engine kwarg. + + It is read by `Database._get_sqla_engine`, not by any driver; splatting it into + `create_engine` kwargs would raise on every engine and leak it to the driver. + """ + database = mocker.MagicMock() + database.encrypted_extra = json.dumps( + { + "embedded_credentials": {"username": "svc", "password": "pw"}, + "connect_args": {"sslmode": "require"}, + } + ) + params: dict[str, Any] = {} + + BaseEngineSpec.update_params_from_encrypted_extra(database, params) + + assert params == {"connect_args": {"sslmode": "require"}} diff --git a/tests/unit_tests/db_engine_specs/test_snowflake.py b/tests/unit_tests/db_engine_specs/test_snowflake.py index 7164d9b2d8b0..1f01f6b01df8 100644 --- a/tests/unit_tests/db_engine_specs/test_snowflake.py +++ b/tests/unit_tests/db_engine_specs/test_snowflake.py @@ -18,7 +18,7 @@ # pylint: disable=import-outside-toplevel from datetime import datetime -from typing import Optional +from typing import Any, Optional from unittest import mock import pytest @@ -30,6 +30,7 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.superset_typing import OAuth2ClientConfig from superset.utils import json +from tests.unit_tests.conftest import with_feature_flags from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm from tests.unit_tests.fixtures.common import dttm # noqa: F401 @@ -856,6 +857,392 @@ def test_snowflake_oauth2_exception_catches_refresh_token_error() -> None: ) +def test_parameters_json_schema_has_encrypted_extra() -> None: + """ + Test that ``parameters_json_schema`` marks ``oauth2_client_info`` with + ``x-encrypted-extra``. + + That annotation is what makes the connection dialog render the OAuth2 client + fields and move their values into ``masked_encrypted_extra`` instead of posting + them as plain parameters. Without it the whole form half of Snowflake's OAuth2 + support is unreachable, and an admin has to hand-edit the Secure extra JSON. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + schema = SnowflakeEngineSpec.parameters_json_schema() + + assert schema is not None + oauth2_client_info = schema["properties"]["oauth2_client_info"] + assert oauth2_client_info["x-encrypted-extra"] is True + assert "default" not in oauth2_client_info + + +def test_parameters_json_schema_keeps_oauth2_client_info_optional() -> None: + """ + Test that adding ``oauth2_client_info`` does not make it a required parameter. + + Every other Snowflake parameter is required; a connection that does not use + OAuth2 must still validate. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + schema = SnowflakeEngineSpec.parameters_json_schema() + + assert schema is not None + assert "oauth2_client_info" in schema["properties"] + assert "oauth2_client_info" not in schema.get("required", []) + assert sorted(schema["required"]) == [ + "account", + "database", + "password", + "role", + "username", + "warehouse", + ] + + +def test_validate_parameters_unaffected_by_oauth2_client_info() -> None: + """ + Test that supplying ``oauth2_client_info`` neither satisfies nor breaks + ``validate_parameters``. + + Snowflake still needs a base credential for the connection test it runs on save, + so the required set must not shift just because OAuth2 is being configured. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + properties: dict[str, Any] = { + "parameters": { + "oauth2_client_info": {"id": "client-id", "secret": "client-secret"}, + } + } + + errors = SnowflakeEngineSpec.validate_parameters(properties) # type: ignore[arg-type] + + assert len(errors) == 1 + assert errors[0].extra is not None + assert errors[0].extra["missing"] == [ + "account", + "database", + "password", + "role", + "username", + "warehouse", + ] + + +def test_build_sqlalchemy_uri_does_not_leak_the_oauth2_client() -> None: + """ + Test that the OAuth2 client id and secret never reach the stored URI. + + ``build_sqlalchemy_uri`` reads only named parameters, so this holds by + construction -- but a client secret leaked into a connection string would be the + worst possible outcome of exposing this field, so it is pinned rather than + reasoned about. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + parameters: dict[str, Any] = { + "username": "username", + "password": "password", + "account": "account", + "database": "database", + "role": "role", + "warehouse": "warehouse", + "oauth2_client_info": { + "id": "super-secret-client-id", + "secret": "super-secret-client-secret", + }, + } + + uri = SnowflakeEngineSpec.build_sqlalchemy_uri( + parameters, # type: ignore[arg-type] + encrypted_extra={ + "oauth2_client_info": { + "id": "super-secret-client-id", + "secret": "super-secret-client-secret", + } + }, + ) + + assert "super-secret-client-id" not in uri + assert "super-secret-client-secret" not in uri + assert "oauth2_client_info" not in uri + + +def test_update_params_from_encrypted_extra_does_not_leak_the_oauth2_client( + mocker: MockerFixture, +) -> None: + """ + Test that the OAuth2 client does not end up in the engine kwargs. + + An unrecognised key reaching ``create_engine(**engine_kwargs)`` would raise, and + a recognised one would be sent to the driver; neither is wanted for the client + config, which is consumed by Superset itself. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + database = mocker.MagicMock() + database.encrypted_extra = json.dumps( + {"oauth2_client_info": {"id": "client-id", "secret": "client-secret"}} + ) + + params: dict[str, object] = {} + SnowflakeEngineSpec.update_params_from_encrypted_extra(database, params) + + assert "oauth2_client_info" not in params + assert "client-secret" not in json.dumps(params, default=str) + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_parameters_json_schema_offers_embedded_credentials_when_enabled() -> None: + """ + Test that the guest-credential sub-form is offered when both flags are on. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + schema = SnowflakeEngineSpec.parameters_json_schema() + + assert schema is not None + assert schema["properties"]["embedded_credentials"]["x-encrypted-extra"] is True + assert "embedded_credentials" not in schema.get("required", []) + + +def _assert_embedded_credentials_hidden() -> None: + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + schema = SnowflakeEngineSpec.parameters_json_schema() + + assert schema is not None + assert "embedded_credentials" not in schema["properties"] + # The OAuth2 client config is unrelated and must still be offered. + assert "oauth2_client_info" in schema["properties"] + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=False, EMBEDDED_SUPERSET=True) +def test_parameters_json_schema_hides_embedded_credentials_when_disabled() -> None: + """ + Test that the sub-form disappears when the feature flag is off. + + This payload is the only source of the fields the connection dialog renders, so + dropping the property is all that is needed to hide the UI -- no feature-flag + plumbing on the frontend. + """ + _assert_embedded_credentials_hidden() + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=False) +def test_parameters_json_schema_hides_embedded_credentials_without_embedding() -> None: + """ + Test that the sub-form also disappears when embedding itself is off. + + `Database.get_embedded_fallback_credentials` requires both flags, so offering + the field with only this one on would save a setting the gate then ignores -- + the "saves and silently does nothing" shape the dialog must not produce. + """ + _assert_embedded_credentials_hidden() + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_parameters_json_schema_reports_the_engine_level_oauth2_client( + mocker: MockerFixture, +) -> None: + """ + Test that the schema tells the dialog whether a deployment-wide client exists. + + The sub-form is only useful on a connection that uses OAuth2, and half of + `Database.is_oauth2_enabled` -- a client in `DATABASE_OAUTH2_CLIENTS` -- is not + visible to the frontend. A property's `default` is the one channel the dialog + plumbs through to a field component, so the answer rides along there. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + mocker.patch.object(SnowflakeEngineSpec, "is_oauth2_enabled", return_value=False) + schema = SnowflakeEngineSpec.parameters_json_schema() + assert schema["properties"]["embedded_credentials"]["default"] == { + "engine_oauth2_client_configured": False, + } + + mocker.patch.object(SnowflakeEngineSpec, "is_oauth2_enabled", return_value=True) + schema = SnowflakeEngineSpec.parameters_json_schema() + assert schema["properties"]["embedded_credentials"]["default"] == { + "engine_oauth2_client_configured": True, + } + + +def test_mask_encrypted_extra_embedded_credentials() -> None: + """ + Test that the fallback password is masked but the username is not. + + An admin needs to see which Snowflake user embedded queries run as; the password + must never be readable back out of the API. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + config = json.dumps( + { + "embedded_credentials": { + "username": "embedded_svc", + "password": "embedded_pw", + }, + } + ) + + masked = json.loads(SnowflakeEngineSpec.mask_encrypted_extra(config) or "{}") + + assert masked["embedded_credentials"]["username"] == "embedded_svc" + assert masked["embedded_credentials"]["password"] == "XXXXXXXXXX" # noqa: S105 + + +def test_unmask_encrypted_extra_embedded_credentials() -> None: + """ + Test that re-saving the mask preserves the stored fallback password. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + old = json.dumps( + { + "embedded_credentials": { + "username": "embedded_svc", + "password": "embedded_pw", + }, + } + ) + new = json.dumps( + { + "embedded_credentials": { + "username": "embedded_svc", + "password": "XXXXXXXXXX", + }, + } + ) + + unmasked = json.loads(SnowflakeEngineSpec.unmask_encrypted_extra(old, new) or "{}") + + assert unmasked["embedded_credentials"]["password"] == "embedded_pw" # noqa: S105 + + +def test_embedded_keypair_secrets_are_masked_and_restored() -> None: + """A saved PEM key and passphrase stay encrypted across connection edits.""" + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + original = json.dumps( + { + "embedded_credentials": { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": "private PEM key", + "privatekey_pass": "key passphrase", + } + } + ) + masked = SnowflakeEngineSpec.mask_encrypted_extra(original) + assert masked is not None + masked_credentials = json.loads(masked)["embedded_credentials"] + assert masked_credentials["username"] == "embedded_svc" + assert masked_credentials["privatekey_body"] == "XXXXXXXXXX" + assert masked_credentials["privatekey_pass"] == "XXXXXXXXXX" # noqa: S105 + assert SnowflakeEngineSpec.unmask_encrypted_extra(original, masked) == original + + +def test_update_params_from_encrypted_extra_prefers_the_fallback_over_keypair( + mocker: MockerFixture, +) -> None: + """ + Test that key-pair auth is not layered on top of the fallback credential. + + The fallback path skips impersonation, so `authenticator` is unset and this method + still runs. Without the guard a connection carrying both would hand the driver a + private key *and* a username/password, making which one wins a driver detail. + + The guard asks `embedded_fallback_applies`, not for the credential itself: the + credential is applied by `apply_embedded_credentials`, and this method only has + to stay out of its way. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + database = mocker.MagicMock() + database.encrypted_extra = json.dumps( + { + "auth_method": "keypair", + "auth_params": {"privatekey_body": "not-a-real-key"}, + } + ) + database.embedded_fallback_applies.return_value = True + + params: dict[str, Any] = {} + SnowflakeEngineSpec.update_params_from_encrypted_extra(database, params) + + assert params == {} + # The credential itself is none of this method's business any more. + database.get_embedded_fallback_credentials.assert_not_called() + + +def test_update_params_from_encrypted_extra_still_applies_keypair_for_a_user( + mocker: MockerFixture, +) -> None: + """ + Test that key-pair auth is untouched when no fallback credential applies. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + database = mocker.MagicMock() + database.encrypted_extra = json.dumps( + { + "auth_method": "keypair", + "auth_params": {"privatekey_body": "not-a-real-key"}, + } + ) + database.embedded_fallback_applies.return_value = False + + params: dict[str, Any] = {} + # Reaching the PEM loader at all is the assertion: the method did not + # short-circuit. The key is deliberately invalid, so the loader rejects it. + with pytest.raises(ValueError, match="Unable to load PEM file"): + SnowflakeEngineSpec.update_params_from_encrypted_extra(database, params) + + +def test_build_sqlalchemy_uri_does_not_leak_the_embedded_credential() -> None: + """ + Test that the embedded guest credential never reaches the stored URI. + + The connection dialog's ``x-encrypted-extra`` promotion leaves a stringified + copy of the field in ``parameters`` as well as moving it into + ``masked_encrypted_extra`` -- confirmed by driving the real modal -- so this + is a value ``build_sqlalchemy_uri`` genuinely receives, not a hypothetical. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + parameters: dict[str, Any] = { + "username": "svc_user", + "password": "svc_pass", + "account": "account", + "database": "database", + "role": "role", + "warehouse": "warehouse", + "embedded_credentials": json.dumps( + {"username": "embedded_svc", "password": "embedded_pw"} + ), + } + + uri = SnowflakeEngineSpec.build_sqlalchemy_uri(parameters) # type: ignore[arg-type] + + assert "embedded_svc" not in uri + assert "embedded_pw" not in uri + assert "embedded_credentials" not in uri + + +def test_snowflake_opts_into_embedded_credential_fallback() -> None: + """ + Test that Snowflake is opted in and the base spec is not. + """ + from superset.db_engine_specs.base import BaseEngineSpec + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + assert SnowflakeEngineSpec.supports_embedded_credential_fallback is True + assert BaseEngineSpec.supports_embedded_credential_fallback is False + + def test_extended_aggregation_func_median_uses_native_snowflake_syntax() -> None: """ Snowflake documents a native `MEDIAN(x)` function, so MEDIAN is @@ -879,3 +1266,116 @@ def test_extended_aggregation_func_median_uses_native_snowflake_syntax() -> None for aggregate in ("STDDEV_SAMP", "VAR_SAMP"): assert SnowflakeEngineSpec.get_extended_aggregation_func(aggregate) is not None + + +def test_embedded_guest_runs_with_the_fallback_users_default_role( + mocker: MockerFixture, +) -> None: + """ + Test that the connection's role is dropped on the embedded login. + + The connection form always writes a role into the URI. A least-privilege + fallback user, or a token restricted to its own role, is not granted it, so + Snowflake would refuse the session; without a role it uses the fallback + user's default role. The warehouse and database are kept. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + url = make_url( + "snowflake://svc_user:svc_pass@xy12345/ANALYTICS_DB" + "?role=ANALYST&warehouse=COMPUTE_WH" + ) + engine_kwargs: dict[str, Any] = {"connect_args": {"role": "ANALYST"}} + + url, engine_kwargs = SnowflakeEngineSpec.apply_embedded_credentials( + mocker.MagicMock(), + {"username": "embedded_svc", "password": "embedded_pw"}, + url, + engine_kwargs, + ) + + assert url.username == "embedded_svc" + assert "role" not in url.query + assert "role" not in engine_kwargs["connect_args"] + assert url.query["warehouse"] == "COMPUTE_WH" + assert url.database == "ANALYTICS_DB" + + +def test_embedded_guest_ignores_the_connections_key_pair_authenticator( + mocker: MockerFixture, +) -> None: + """ + Test that the connection's authenticator is dropped on the embedded login. + + A URI naming ``authenticator=SNOWFLAKE_JWT`` would otherwise make the driver + expect a key pair from a password guest and fail before reaching Snowflake. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + url = make_url( + "snowflake://svc_user@xy12345/ANALYTICS_DB" + "?warehouse=COMPUTE_WH&authenticator=SNOWFLAKE_JWT" + ) + engine_kwargs: dict[str, Any] = {"connect_args": {"authenticator": "SNOWFLAKE_JWT"}} + + url, engine_kwargs = SnowflakeEngineSpec.apply_embedded_credentials( + mocker.MagicMock(), + {"username": "embedded_svc", "password": "embedded_pw"}, + url, + engine_kwargs, + ) + + assert url.username == "embedded_svc" + assert url.password == "embedded_pw" # noqa: S105 + assert "authenticator" not in url.query + assert "authenticator" not in engine_kwargs["connect_args"] + assert url.query["warehouse"] == "COMPUTE_WH" + + +@pytest.mark.parametrize("key_pair", [False, True]) +def test_embedded_guest_never_signs_in_with_the_connections_own_credentials( + mocker: MockerFixture, key_pair: bool +) -> None: + """ + Test that the connection's own sign-in arguments are dropped on a guest login. + + The driver merges the URI query and ``connect_args`` into its arguments, so a + user/password, key or token left in either would win over the embedded + account. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + mocker.patch.object(SnowflakeEngineSpec, "_load_private_key", return_value=b"der") + url = make_url( + "snowflake://svc_user:svc_pass@xy12345/ANALYTICS_DB" + "?warehouse=COMPUTE_WH&user=base_user&password=base_pw" + "&private_key_file=/keys/base.p8&token=base-token" + ) + engine_kwargs: dict[str, Any] = { + "connect_args": { + "user": "base_user", + "password": "base_pw", + "private_key_file": "/keys/base.p8", + "private_key_file_pwd": "base-passphrase", + "token": "base-token", + "client_session_keep_alive": True, + } + } + credentials = ( + {"username": "embedded_svc", "auth_method": "keypair", "privatekey_body": "x"} + if key_pair + else {"username": "embedded_svc", "password": "embedded_pw"} + ) + + url, engine_kwargs = SnowflakeEngineSpec.apply_embedded_credentials( + mocker.MagicMock(), credentials, url, engine_kwargs + ) + + connect_args = engine_kwargs["connect_args"] + assert url.username == "embedded_svc" + assert dict(url.query) == {"warehouse": "COMPUTE_WH"} + rendered = url.render_as_string(hide_password=False) + repr(connect_args) + for base_value in ("base_user", "base_pw", "base.p8", "base-token"): + assert base_value not in rendered + assert connect_args["client_session_keep_alive"] is True + assert (connect_args.get("private_key") == b"der") is key_pair diff --git a/tests/unit_tests/importexport/api_test.py b/tests/unit_tests/importexport/api_test.py index ebf249716a83..65c5e58d655f 100644 --- a/tests/unit_tests/importexport/api_test.py +++ b/tests/unit_tests/importexport/api_test.py @@ -20,9 +20,12 @@ from io import BytesIO from pathlib import Path from typing import Any +from uuid import UUID from zipfile import is_zipfile, ZIP_DEFLATED, ZipFile +import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm.session import Session from superset import security_manager from superset.utils import json @@ -500,3 +503,199 @@ def test_import_assets_no_contents( } ] } + + +@pytest.mark.parametrize( + "resource,module,command", + [ + ("dashboard", "superset.dashboards.api", "ImportDashboardsCommand"), + ("chart", "superset.charts.api", "ImportChartsCommand"), + ("dataset", "superset.datasets.api", "ImportDatasetsCommand"), + ( + "saved_query", + "superset.queries.saved_queries.api", + "ImportSavedQueriesCommand", + ), + ], +) +def test_bundle_imports_pass_encrypted_extra_secrets( + mocker: MockerFixture, + client: Any, + full_api_access: None, + resource: str, + module: str, + command: str, +) -> None: + """ + Dashboard, chart, dataset and saved-query imports pass the secrets for their + databases' masked Secure Extra to the command, as the database import does. + + Bundles carry the masked block, so without this a promoted connection could + never get its OAuth2 client secret or embedded credential back. + """ + import_command = mocker.patch(f"{module}.{command}") + buf = BytesIO() + with ZipFile(buf, "w") as bundle: + with bundle.open("export/metadata.yaml", "w") as fp: + fp.write(b"version: 1.0.0\n") + buf.seek(0) + secrets = { + "export/databases/promoted.yaml": {"$.oauth2_client_info.secret": "s3cret"} + } + + response = client.post( + f"/api/v1/{resource}/import/", + data={ + "formData": (buf, "export.zip"), + "encrypted_extra_secrets": json.dumps(secrets), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 200, response.json + assert import_command.call_args.kwargs["encrypted_extra_secrets"] == secrets + + +EMBEDDED_KEYPAIR = { + "username": "EMBED_SVC", + "auth_method": "keypair", + "privatekey_body": "-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n", + "privatekey_pass": "passphrase", +} + + +@pytest.mark.parametrize( + "fallback_enabled,incoming,status,stored", + [ + pytest.param( + True, + {"username": "EMBED_SVC", "auth_method": "keypair", "privatekey_pass": "X"}, + 400, + EMBEDDED_KEYPAIR, + id="private-key-missing", + ), + pytest.param( + True, + {**EMBEDDED_KEYPAIR, "privatekey_body": "X", "privatekey_pass": "X"}, + 200, + EMBEDDED_KEYPAIR, + id="complete", + ), + pytest.param( + False, + {"username": "EMBED_SVC", "auth_method": "keypair", "privatekey_pass": "X"}, + 200, + { + "username": "EMBED_SVC", + "auth_method": "keypair", + "privatekey_pass": "passphrase", + }, + id="fallback-off", + ), + ], +) +def test_import_assets_overwrite_embedded_credential( + mocker: MockerFixture, + session: Session, + client: Any, + full_api_access: None, + fallback_enabled: bool, + incoming: dict[str, str], + status: int, + stored: dict[str, str], +) -> None: + """ + An overwrite import whose embedded guest credential leaves out a required + value is refused with a message naming it, and the stored credential stays + (sc-127530). A complete credential, or the fallback off, imports as before. + + ``"X"`` in ``incoming`` stands for the export mask, which the import reveals + from the stored credential. + """ + import yaml + + from superset import db + from superset.constants import PASSWORD_MASK + from superset.extensions import feature_flag_manager + from superset.models.core import Database + + flags = { + "EMBEDDED_SUPERSET": True, + "EMBEDDED_CREDENTIAL_FALLBACK": fallback_enabled, + } + mocker.patch.object( + feature_flag_manager, + "get_feature_flags", + return_value={**(feature_flag_manager._feature_flags or {}), **flags}, + ) + mocker.patch.object(security_manager, "can_access", return_value=True) + mocker.patch("superset.commands.database.importers.v1.utils.add_permissions") + mocker.patch( + "superset.commands.importers.v1.assets.get_default_viewers_for_current_user", + return_value=[], + ) + + def mask(credentials: dict[str, str]) -> dict[str, str]: + return {k: PASSWORD_MASK if v == "X" else v for k, v in credentials.items()} + + db_uuid = "6c1c5ee7-1d83-4f57-9a8e-1a2b3c4d5e6f" + Database.metadata.create_all(db.session.get_bind()) # pylint: disable=no-member + db.session.add( + Database( + database_name="snowflake_embedded", + sqlalchemy_uri="snowflake://base_user@account/db", + uuid=UUID(db_uuid), + impersonate_user=True, + encrypted_extra=json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "client-secret"}, + "embedded_credentials": EMBEDDED_KEYPAIR, + } + ), + ) + ) + db.session.commit() + + contents = { + "metadata.yaml": yaml.safe_dump( + {"version": "1.0.0", "type": "assets", "timestamp": "2026-10-29T00:00:00"} + ), + "databases/snowflake_embedded.yaml": yaml.safe_dump( + { + "database_name": "snowflake_embedded", + "sqlalchemy_uri": "snowflake://base_user@account/db", + "uuid": db_uuid, + "version": "1.0.0", + "impersonate_user": True, + "extra": {}, + "masked_encrypted_extra": json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": PASSWORD_MASK}, + "embedded_credentials": mask(incoming), + } + ), + } + ), + } + buf = BytesIO() + with ZipFile(buf, "w") as bundle: + for path, body in contents.items(): + with bundle.open(f"assets_export/{path}", "w") as fp: + fp.write(body.encode()) + buf.seek(0) + + response = client.post( + "/api/v1/assets/import/", + data={"bundle": (buf, "assets_export.zip")}, + content_type="multipart/form-data", + ) + + assert response.status_code == status, response.json + if status != 200: + assert ( + "$.embedded_credentials.privatekey_body" + in (response.json["errors"][0]["message"]) + ) + db.session.expire_all() + database = db.session.query(Database).filter_by(uuid=UUID(db_uuid)).one() + assert json.loads(database.encrypted_extra)["embedded_credentials"] == stored diff --git a/tests/unit_tests/models/core_test.py b/tests/unit_tests/models/core_test.py index 54a412698e3e..dddd95415db3 100644 --- a/tests/unit_tests/models/core_test.py +++ b/tests/unit_tests/models/core_test.py @@ -23,6 +23,7 @@ import pandas as pd import pytest +import sqlalchemy.exc from flask import current_app from marshmallow import ValidationError from pytest_mock import MockerFixture @@ -35,7 +36,7 @@ Table as SqlalchemyTable, ) from sqlalchemy.engine.reflection import Inspector -from sqlalchemy.engine.url import make_url +from sqlalchemy.engine.url import make_url, URL from sqlalchemy.orm.session import Session from sqlalchemy.sql import Select @@ -3010,3 +3011,1066 @@ def test_connection_does_not_re_exchange_a_refused_refresh_token( isolated_session.assert_not_called() start_dance.assert_called_once_with(database) engine.raw_connection.assert_not_called() + + +def _embedded_snowflake_database( + *, + username: str = "embedded_svc", + password: str = "embedded_pw", # noqa: S107 +) -> "Database": + """ + A Snowflake connection carrying both an OAuth2 client and a guest credential. + """ + from superset.models.core import Database + + encrypted_extra: dict[str, Any] = { + "oauth2_client_info": { + "id": "client-id", + "secret": "client-secret", + "scope": "refresh_token", + "authorization_request_uri": "https://xy12345.snowflakecomputing.com/oauth/authorize", + "token_request_uri": "https://xy12345.snowflakecomputing.com/oauth/token-request", + }, + } + if username or password: + encrypted_extra["embedded_credentials"] = { + "username": username, + "password": password, + } + + return Database( + database_name="snowflake_db", + sqlalchemy_uri=( + "snowflake://svc_user:svc_pass@xy12345.us-east-2.aws/ANALYTICS_DB" + "?role=ANALYST&warehouse=COMPUTE_WH" + ), + impersonate_user=True, + encrypted_extra=json.dumps(encrypted_extra), + ) + + +def _become_guest(mocker: MockerFixture, username: str = "guest_user") -> None: + """ + Install an embedded guest as the current principal. + """ + from superset.security.guest_token import GuestToken, GuestUser + + token: GuestToken = { + "user": {"username": username}, + "resources": [], + "rls_rules": [], + "iat": 0, + "exp": 1, + } + guest = GuestUser(token=token, roles=[]) + mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest", + return_value=guest, + ) + mocker.patch("superset.models.core.get_username", return_value=guest.username) + + +def _stub_driver_dependent_uri_validation(mocker: MockerFixture) -> None: + """ + Make URI validation driver-agnostic for these tests. + + ``validate_database_uri`` resolves the dialect to look up disallowed query + params, which needs ``snowflake-sqlalchemy`` installed. That is an optional + extra and is absent from the unit-test environment, and it has nothing to do + with which credential is selected, so it is stubbed rather than depended on. + """ + mocker.patch("superset.db_engine_specs.base.BaseEngineSpec.validate_database_uri") + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_fallback_credential_is_used_for_a_guest( + mocker: MockerFixture, +) -> None: + """ + Test that an embedded guest connects with the stored fallback credential. + + The guest URL must carry the fallback username and password and must *not* carry + `authenticator=oauth`: skipping impersonation is the whole mechanism, because + Snowflake's `impersonate_user` sets that authenticator whether or not a per-user + token exists -- and a guest never has one. + """ + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + + _embedded_snowflake_database()._get_sqla_engine(nullpool=False) + + url = create_engine_mock.call_args[0][0] + assert url.username == "embedded_svc" + assert url.password == "embedded_pw" # noqa: S105 + assert "authenticator" not in url.query + + +@pytest.mark.parametrize("passphrase", ["", "key-passphrase"]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_keypair_is_used_only_for_a_guest( + mocker: MockerFixture, passphrase: str +) -> None: + """A guest uses the PEM key while a logged-in user stays on OAuth2.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + from superset.models.core import _ENGINE_CACHE + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + encryption = ( + serialization.BestAvailableEncryption(passphrase.encode()) + if passphrase + else serialization.NoEncryption() + ) + pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + encryption, + ).decode() + expected_der = private_key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + database = _embedded_snowflake_database() + extra = database.get_encrypted_extra() + extra["embedded_credentials"] = { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": pem, + "privatekey_pass": passphrase, + } + extra["auth_method"] = "keypair" + extra["auth_params"] = {"privatekey_body": "not-the-embedded-key"} + database.encrypted_extra = json.dumps(extra) + database.id = 1 + + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + side_effect=lambda *a, **kw: create_engine("sqlite://"), + ) + _stub_driver_dependent_uri_validation(mocker) + _ENGINE_CACHE.clear() + + _become_guest(mocker, username="ACCOUNTADMIN") + database._get_sqla_engine(nullpool=False) + guest_url = create_engine_mock.call_args[0][0] + guest_kwargs = create_engine_mock.call_args[1] + assert guest_url.username == "embedded_svc" + assert guest_url.password is None + assert "authenticator" not in guest_url.query + assert guest_kwargs["connect_args"]["private_key"] == expected_der + + mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest", + return_value=None, + ) + with current_app.test_request_context("/dashboard/"): + database._get_sqla_engine(nullpool=False) + user_url = create_engine_mock.call_args[0][0] + user_kwargs = create_engine_mock.call_args[1] + assert user_url.query["authenticator"] == "oauth" + assert "private_key" not in user_kwargs["connect_args"] + assert len(_ENGINE_CACHE) == 2 + _ENGINE_CACHE.clear() + + +@pytest.mark.parametrize( + "credentials", + [ + {"username": " ", "password": "pw"}, + {"username": " \t ", "auth_method": "keypair", "privatekey_body": "PEM"}, + {"username": "embedded_svc", "auth_method": "keypair"}, + {"auth_method": "keypair", "privatekey_body": "PEM"}, + {"username": "embedded_svc", "auth_method": "keypair", "password": "pw"}, + {"username": "embedded_svc", "auth_method": "unknown", "password": "pw"}, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_incomplete_embedded_keypair_is_ignored( + mocker: MockerFixture, credentials: dict[str, str] +) -> None: + """An incomplete or unknown fallback method cannot authenticate a guest.""" + _become_guest(mocker) + database = _embedded_snowflake_database() + extra = database.get_encrypted_extra() + extra["embedded_credentials"] = credentials + database.encrypted_extra = json.dumps(extra) + + assert database.get_embedded_fallback_credentials() is None + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_guest_username_from_the_token_never_reaches_the_connection( + mocker: MockerFixture, +) -> None: + """ + Test that the guest token's own username claim cannot become the connection + identity. + + Whoever mints the guest token controls its `username`. If the fallback path went + through impersonation, that claim would be used to connect -- so a token minted + with `ACCOUNTADMIN` would try to connect as `ACCOUNTADMIN`. + """ + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + _become_guest(mocker, username="ACCOUNTADMIN") + _stub_driver_dependent_uri_validation(mocker) + + _embedded_snowflake_database()._get_sqla_engine(nullpool=False) + + url = create_engine_mock.call_args[0][0] + assert url.username == "embedded_svc" + assert "ACCOUNTADMIN" not in url.render_as_string(hide_password=False) + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_connection_mutator_receives_the_embedded_connection_identity( + mocker: MockerFixture, +) -> None: + """A username-forwarding hook must retain the configured guest identity.""" + from flask import current_app + + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + _become_guest(mocker, username="ACCOUNTADMIN") + _stub_driver_dependent_uri_validation(mocker) + mutator = mocker.Mock( + side_effect=lambda url, kwargs, username, security_manager, source: ( + url.set(username=username), + kwargs, + ) + ) + mocker.patch.dict(current_app.config, {"DB_CONNECTION_MUTATOR": mutator}) + + _embedded_snowflake_database()._get_sqla_engine(nullpool=False) + + assert mutator.call_args.args[2] == "embedded_svc" + assert create_engine_mock.call_args.args[0].username == "embedded_svc" + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_fallback_credential_is_not_used_for_a_logged_in_user( + mocker: MockerFixture, +) -> None: + """ + Test that a logged-in user on the same connection never sees the credential. + """ + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest", + return_value=None, + ) + mocker.patch("superset.models.core.get_username", return_value="alice") + _stub_driver_dependent_uri_validation(mocker) + + _embedded_snowflake_database()._get_sqla_engine(nullpool=False) + + rendered = create_engine_mock.call_args[0][0].render_as_string(hide_password=False) + assert "embedded_svc" not in rendered + assert "embedded_pw" not in rendered + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_guest_and_admin_do_not_share_an_engine_cache_entry( + mocker: MockerFixture, +) -> None: + """ + Test that a guest and a logged-in user get distinct engines for one connection. + + The engine cache is keyed on (database id, rendered URL, engine kwargs) with no + user component. Putting the fallback credential on the URL -- rather than into + `connect_args` -- is what keeps the two principals apart; routing it through + `connect_args` would reach the key only via `repr()` of a nested dict. + """ + from superset.models.core import _ENGINE_CACHE + + mocker.patch( + "superset.models.core.create_engine", + side_effect=lambda *a, **kw: create_engine("sqlite://"), + ) + _ENGINE_CACHE.clear() + _stub_driver_dependent_uri_validation(mocker) + + database = _embedded_snowflake_database() + database.id = 1 + + guest_patch = mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest" + ) + mocker.patch("superset.models.core.get_username", return_value="alice") + + _become_guest(mocker) + database._get_sqla_engine(nullpool=False) + + guest_patch.return_value = None + mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest", + return_value=None, + ) + database._get_sqla_engine(nullpool=False) + + urls = {key[1] for key in _ENGINE_CACHE} + assert len(_ENGINE_CACHE) == 2, _ENGINE_CACHE + assert any("embedded_svc" in url for url in urls) + assert any("svc_user" in url for url in urls) + _ENGINE_CACHE.clear() + + +@pytest.mark.parametrize( + "username,password", + [("embedded_svc", ""), ("", "embedded_pw"), ("", "")], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_half_filled_embedded_credential_is_ignored( + mocker: MockerFixture, + username: str, + password: str, +) -> None: + """ + Test that an incomplete credential is ignored rather than half-applied. + """ + _become_guest(mocker) + + database = _embedded_snowflake_database(username=username, password=password) + + assert database.get_embedded_fallback_credentials() is None + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=False, EMBEDDED_SUPERSET=True) +def test_embedded_fallback_credential_requires_its_feature_flag( + mocker: MockerFixture, +) -> None: + """ + Test that the kill switch works on an otherwise fully configured connection. + """ + _become_guest(mocker) + + assert _embedded_snowflake_database().get_embedded_fallback_credentials() is None + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=False) +def test_embedded_fallback_credential_requires_embedded_superset( + mocker: MockerFixture, +) -> None: + """ + Test that the credential is unreachable when embedding itself is disabled. + + `get_current_guest_user_if_guest` is a bare isinstance check with no flag behind + it, so this is checked explicitly rather than inherited. + """ + _become_guest(mocker) + + assert _embedded_snowflake_database().get_embedded_fallback_credentials() is None + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_fallback_credential_requires_an_oauth2_connection( + mocker: MockerFixture, +) -> None: + """ + Test that an ordinary connection does not grow a second identity. + + Without this condition a plain username/password Snowflake connection with the + field filled in would silently route guests elsewhere, which is wider than the + feature's purpose: a fallback for the per-user OAuth2 flow. + """ + from superset.models.core import Database + + _become_guest(mocker) + mocker.patch.dict(current_app.config, {"DATABASE_OAUTH2_CLIENTS": {}}) + + database = Database( + database_name="snowflake_db", + sqlalchemy_uri="snowflake://svc_user:svc_pass@xy12345/ANALYTICS_DB", + impersonate_user=True, + encrypted_extra=json.dumps( + { + "embedded_credentials": { + "username": "embedded_svc", + "password": "embedded_pw", + } + } + ), + ) + + assert database.get_embedded_fallback_credentials() is None + + # Supplying only the missing OAuth2 client makes this connection eligible. + database.encrypted_extra = _embedded_snowflake_database().encrypted_extra + assert database.get_embedded_fallback_credentials() == { + "username": "embedded_svc", + "password": "embedded_pw", + } + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_fallback_credential_requires_engine_opt_in( + mocker: MockerFixture, +) -> None: + """ + Test that an engine which has not opted in ignores the credential. + """ + from superset.models.core import Database + + _become_guest(mocker) + + database = Database( + database_name="pg_db", + sqlalchemy_uri="postgresql://svc_user:svc_pass@localhost/db", + impersonate_user=True, + encrypted_extra=json.dumps( + { + "oauth2_client_info": { + "id": "client-id", + "secret": "client-secret", + "scope": "refresh_token", + "authorization_request_uri": "https://oauth.example/authorize", + "token_request_uri": "https://oauth.example/token", + }, + "embedded_credentials": { + "username": "embedded_svc", + "password": "embedded_pw", + }, + } + ), + ) + + assert not database.db_engine_spec.supports_embedded_credential_fallback + assert database.get_embedded_fallback_credentials() is None + + # The engine opt-in is the only condition preventing fallback here. + mocker.patch.object( + database.db_engine_spec, "supports_embedded_credential_fallback", True + ) + assert database.get_embedded_fallback_credentials() == { + "username": "embedded_svc", + "password": "embedded_pw", + } + + +@pytest.mark.parametrize( + "username,password", + [("embedded_svc", ""), ("", "embedded_pw"), ("", "")], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_guest_is_refused_rather_than_run_on_the_base_credential( + mocker: MockerFixture, + username: str, + password: str, +) -> None: + """ + Test that a guest with no applicable credential cannot reach the base one. + + Falling through to the connection URL's own username and password is the + failure this feature exists to prevent: the embedded viewer would silently + query as whatever service account the connection was built with. Neither the + viewer nor the dashboard author can see that, so it is refused instead. + """ + from superset.exceptions import SupersetErrorException + + create_engine_mock = mocker.patch("superset.models.core.create_engine") + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + + database = _embedded_snowflake_database(username=username, password=password) + + with pytest.raises(SupersetErrorException) as excinfo: + database._get_sqla_engine(nullpool=False) + + assert excinfo.value.error.error_type == ( + SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR + ) + # A configuration problem, so monitoring must not count it as a server fault. + assert excinfo.value.status == 400 + create_engine_mock.assert_not_called() + + +@pytest.mark.parametrize("global_client", [False, True]) +@pytest.mark.parametrize("has_credentials", [False, True]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_embedded_fallback_does_not_change_service_account_connections( + mocker: MockerFixture, global_client: bool, has_credentials: bool +) -> None: + """OAuth2 client configuration alone must not change a service-account query.""" + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", return_value=create_engine("sqlite://") + ) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + + database = ( + _embedded_snowflake_database() + if has_credentials + else _embedded_snowflake_database(username="", password="") + ) + database.impersonate_user = False + extra = database.get_encrypted_extra() + client = extra.pop("oauth2_client_info") + mocker.patch.dict( + current_app.config, + {"DATABASE_OAUTH2_CLIENTS": {"Snowflake": client} if global_client else {}}, + ) + if global_client: + database.encrypted_extra = json.dumps(extra) + + assert not database.embedded_fallback_applies() + assert database.get_embedded_fallback_credentials() is None + database._get_sqla_engine(nullpool=False) + url = create_engine_mock.call_args[0][0] + assert url.username == "svc_user" + assert url.password == "svc_pass" # noqa: S105 + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=False, EMBEDDED_SUPERSET=True) +def test_a_guest_is_not_refused_when_the_feature_flag_is_off( + mocker: MockerFixture, +) -> None: + """ + Test that the refusal is scoped to deployments that opted in. + + The flag is the whole opt-in: a deployment that has not turned it on keeps its + existing behaviour, including on connections this feature would otherwise have + an opinion about. + """ + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + + _embedded_snowflake_database(username="", password="")._get_sqla_engine( + nullpool=False + ) + + create_engine_mock.assert_called_once() + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_logged_in_user_is_not_refused_when_no_credential_is_configured( + mocker: MockerFixture, +) -> None: + """ + Test that the refusal applies to embedded guests only. + + A logged-in user on an OAuth2 connection has their own per-user token path and + must keep it, whether or not a guest credential happens to be configured. + """ + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest", + return_value=None, + ) + _stub_driver_dependent_uri_validation(mocker) + + _embedded_snowflake_database(username="", password="")._get_sqla_engine( + nullpool=False + ) + + create_engine_mock.assert_called_once() + + +@pytest.mark.parametrize( + "credentials", + [ + {"username": "embedded_svc", "auth_method": "keypair"}, + {"auth_method": "keypair", "privatekey_body": "PEM"}, + {"username": "embedded_svc", "auth_method": "unknown", "password": "pw"}, + ], +) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_guest_is_refused_for_an_incomplete_keypair_too( + mocker: MockerFixture, + credentials: dict[str, str], +) -> None: + """ + Test that the refusal covers every auth method, not just passwords. + + An incomplete key pair -- or an auth method this version does not know -- is + ignored by the gate exactly as a half-filled password is, so it must reach the + same refusal rather than quietly connecting as the URL's own user. + """ + from superset.exceptions import SupersetErrorException + + create_engine_mock = mocker.patch("superset.models.core.create_engine") + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + + database = _embedded_snowflake_database() + extra = database.get_encrypted_extra() + extra["embedded_credentials"] = credentials + database.encrypted_extra = json.dumps(extra) + + with pytest.raises(SupersetErrorException): + database._get_sqla_engine(nullpool=False) + + create_engine_mock.assert_not_called() + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_an_engine_spec_can_change_how_the_credential_is_applied( + mocker: MockerFixture, +) -> None: + """ + Test that applying the credential is the engine spec's decision, not the model's. + + `Database._get_sqla_engine` owns *when* a fallback credential may be used and + delegates *how* it reaches the driver. That split is what lets an engine whose + driver does not take a username and password on the SQLAlchemy URL support the + fallback without the model learning anything about it -- and what keeps a + downstream fork's delta inside its own engine spec. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + class CustomSnowflakeEngineSpec(SnowflakeEngineSpec): + @classmethod + def apply_embedded_credentials( + cls, + database: Database, + credentials: dict[str, str], + url: URL, + engine_kwargs: dict[str, Any], + ) -> tuple[URL, dict[str, Any]]: + engine_kwargs.setdefault("connect_args", {})["token"] = credentials[ + "password" + ] + return ( + URL.create( + drivername=url.drivername, + username="custom_svc", + host=url.host, + database=url.database, + query=url.query, + ), + engine_kwargs, + ) + + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite://"), + ) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + + database = _embedded_snowflake_database() + database.get_db_engine_spec = mocker.MagicMock( + return_value=CustomSnowflakeEngineSpec + ) + + database._get_sqla_engine(nullpool=False) + + url = create_engine_mock.call_args[0][0] + engine_kwargs = create_engine_mock.call_args[1] + assert url.username == "custom_svc" + assert url.password is None + assert engine_kwargs["connect_args"]["token"] == "embedded_pw" # noqa: S105 + # Still the fallback arm, not impersonation: the override replaces how the + # credential is applied, not the decision to skip the OAuth2 authenticator. + assert "authenticator" not in url.query + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_an_engine_spec_can_change_what_counts_as_a_credential( + mocker: MockerFixture, +) -> None: + """ + Test that the credential's shape belongs to the engine spec too. + + The canonical rule accepts a password or a PEM key pair, which covers the engines + in this repository. An engine whose driver authenticates some other way overrides + `validate_embedded_credentials` instead of widening the rule for everyone. + """ + from superset.databases.utils import validated_embedded_credentials + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + class TokenCredentialEngineSpec(SnowflakeEngineSpec): + @classmethod + def validate_embedded_credentials( + cls, credentials: Any + ) -> dict[str, str] | None: + if isinstance(credentials, dict) and credentials.get("token"): + return { + "username": credentials["username"], + "token": credentials["token"], + } + return None + + _become_guest(mocker) + + credentials = {"username": "embedded_svc", "token": "t0ken"} + database = _embedded_snowflake_database() + extra = database.get_encrypted_extra() + extra["embedded_credentials"] = credentials + database.encrypted_extra = json.dumps(extra) + + # The canonical rule rejects this shape, so the gate alone cannot be what + # accepts it. + assert validated_embedded_credentials(credentials) is None + + database.get_db_engine_spec = mocker.MagicMock( + return_value=TokenCredentialEngineSpec + ) + + assert database.get_embedded_fallback_credentials() == credentials + + +@pytest.mark.parametrize("global_client", [False, True]) +@pytest.mark.parametrize("request_context", [False, True]) +@pytest.mark.parametrize("auth_method", ["password", "keypair"]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_restored_embedded_guest_uses_fallback_with_or_without_request_context( + mocker: MockerFixture, global_client: bool, request_context: bool, auth_method: str +) -> None: + """A restored async guest must use the same identity as a synchronous guest.""" + from flask import has_request_context + + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + from superset.security.guest_token import GuestToken, GuestUser + from superset.utils.core import override_user + + token: GuestToken = { + "user": {"username": "ACCOUNTADMIN"}, + "resources": [], + "rls_rules": [], + "iat": 0, + "exp": 1, + } + guest = GuestUser(token=token, roles=[]) + database = _embedded_snowflake_database() + extra = database.get_encrypted_extra() + client = extra.pop("oauth2_client_info") + if not global_client: + extra["oauth2_client_info"] = client + if auth_method == "keypair": + extra["embedded_credentials"] = { + "username": "embedded_svc", + "auth_method": "keypair", + "privatekey_body": "PEM", + } + mocker.patch.object( + SnowflakeEngineSpec, "_load_private_key", return_value=b"guest-key" + ) + database.encrypted_extra = json.dumps(extra) + mocker.patch.dict( + current_app.config, + { + "DATABASE_OAUTH2_CLIENTS": {"Snowflake": client} if global_client else {}, + "DATABASE_OAUTH2_REDIRECT_URI": None, + "SERVER_NAME": None, + }, + ) + _stub_driver_dependent_uri_validation(mocker) + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", return_value=create_engine("sqlite://") + ) + context = ( + current_app.test_request_context("/dashboard/") + if request_context + else nullcontext() + ) + with current_app.app_context(), context, override_user(guest): + assert has_request_context() == request_context + assert SnowflakeEngineSpec.is_oauth2_enabled() == ( + global_client and request_context + ) + database._get_sqla_engine(nullpool=False) + + url = create_engine_mock.call_args[0][0] + connect_args = create_engine_mock.call_args[1]["connect_args"] + assert url.username == "embedded_svc" + assert "authenticator" not in url.query + assert "authenticator" not in connect_args + if auth_method == "keypair": + assert url.password is None + assert connect_args["private_key"] == b"guest-key" + else: + assert url.password == "embedded_pw" # noqa: S105 + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_async_embedded_guest_without_credentials_is_refused( + mocker: MockerFixture, +) -> None: + """A globally configured async connection cannot fall back to its base user.""" + from superset.exceptions import SupersetErrorException + + database = _embedded_snowflake_database(username="", password="") + extra = database.get_encrypted_extra() + client = extra.pop("oauth2_client_info") + database.encrypted_extra = json.dumps(extra) + mocker.patch.dict( + current_app.config, {"DATABASE_OAUTH2_CLIENTS": {"Snowflake": client}} + ) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + create_engine_mock = mocker.patch("superset.models.core.create_engine") + with pytest.raises(SupersetErrorException) as excinfo: + database._get_sqla_engine(nullpool=False) + assert ( + excinfo.value.error.error_type + == SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR + ) + create_engine_mock.assert_not_called() + + +def _promoted_snowflake_database(credential_survived: bool) -> "Database": + """ + A connection as dashboard/chart import leaves it. + + Bundles do not carry ``encrypted_extra``, so the copy keeps "Impersonate logged + in user" but loses its OAuth2 client and, usually, the embedded credential. + """ + database = _embedded_snowflake_database() + extra = database.get_encrypted_extra() + extra.pop("oauth2_client_info") + if not credential_survived: + extra.pop("embedded_credentials") + database.encrypted_extra = json.dumps(extra) + return database + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_guest_on_an_incomplete_oauth2_client_is_refused( + mocker: MockerFixture, +) -> None: + """An incomplete client gets the refusal, not `get_oauth2_config()`'s raise.""" + from superset.exceptions import SupersetErrorException + + mocker.patch("superset.models.core.create_engine") + mocker.patch.dict(current_app.config, {"DATABASE_OAUTH2_CLIENTS": {}}) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + database = _promoted_snowflake_database(credential_survived=False) + database.encrypted_extra = json.dumps({"oauth2_client_info": {"id": "client"}}) + + with pytest.raises(SupersetErrorException) as excinfo: + database._get_sqla_engine(nullpool=False) + + assert excinfo.value.status == 400 + + +@pytest.mark.parametrize("credential_survived", [False, True]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_guest_on_a_promoted_connection_is_refused_with_an_actionable_message( + mocker: MockerFixture, credential_survived: bool +) -> None: + """ + A guest on an impersonating connection with no OAuth2 client is refused. + + Serving them through impersonation would run on whatever identity the engine + derives from the URL -- for Snowflake, the base account -- with no error or + warning. + """ + from superset.exceptions import SupersetErrorException + + create_engine_mock = mocker.patch("superset.models.core.create_engine") + mocker.patch.dict(current_app.config, {"DATABASE_OAUTH2_CLIENTS": {}}) + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + database = _promoted_snowflake_database(credential_survived) + + assert database.embedded_guest_missing_oauth2_client() + assert not database.embedded_fallback_applies() + with pytest.raises(SupersetErrorException) as excinfo: + database._get_sqla_engine(nullpool=False) + + error = excinfo.value.error + assert error.error_type == SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR + assert "OAuth2 client information" in error.message + assert "imported from another environment" in error.message + assert excinfo.value.status == 400 + create_engine_mock.assert_not_called() + + +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_logged_in_user_on_a_promoted_connection_is_not_refused( + mocker: MockerFixture, +) -> None: + """The refusal is for embedded guests; logged-in users keep impersonation.""" + create_engine_mock = mocker.patch( + "superset.models.core.create_engine", return_value=create_engine("sqlite://") + ) + mocker.patch.dict(current_app.config, {"DATABASE_OAUTH2_CLIENTS": {}}) + mocker.patch( + "superset.models.core.security_manager.get_current_guest_user_if_guest", + return_value=None, + ) + _stub_driver_dependent_uri_validation(mocker) + database = _promoted_snowflake_database(credential_survived=False) + + assert not database.embedded_guest_missing_oauth2_client() + database._get_sqla_engine(nullpool=False) + + create_engine_mock.assert_called_once() + + +@pytest.mark.parametrize( + "condition", ["impersonation_off", "flag_off", "engine_not_opted_in"] +) +def test_the_promoted_connection_refusal_needs_every_condition( + mocker: MockerFixture, condition: str +) -> None: + """ + Without any one condition the guest is not refused on this ground. + + Impersonation off is an ordinary service-account connection; with the flag or + the engine opt-in off, the deployment never asked for this behaviour. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + flags = {"EMBEDDED_CREDENTIAL_FALLBACK": condition != "flag_off"} + mocker.patch( + "superset.models.core.is_feature_enabled", + side_effect=lambda name: flags.get(name, True), + ) + mocker.patch.object( + SnowflakeEngineSpec, + "supports_embedded_credential_fallback", + condition != "engine_not_opted_in", + ) + mocker.patch.dict(current_app.config, {"DATABASE_OAUTH2_CLIENTS": {}}) + _become_guest(mocker) + database = _promoted_snowflake_database(credential_survived=False) + database.impersonate_user = condition != "impersonation_off" + + assert not database.embedded_guest_missing_oauth2_client() + + +@pytest.mark.parametrize( + "sqlalchemy_uri", + ["postgresql://user:pass@host/db", "mysql://user:pass@host/db"], +) +def test_superset_owned_secure_extra_never_reaches_the_driver( + sqlalchemy_uri: str, +) -> None: + """ + Test that `oauth2_client_info` and `embedded_credentials` are never passed to + `create_engine`, whatever the engine's own handler copies across. + + Engines like Postgres copy all of `encrypted_extra` into the engine kwargs, so + a connection switched to them from Snowflake used to fail every connect with + a `TypeError` -- a 500 on the very save that switched it. + """ + database = Database( + database_name="switched", + sqlalchemy_uri=sqlalchemy_uri, + encrypted_extra=json.dumps( + { + "oauth2_client_info": {"id": "client", "secret": "s3cret"}, + "embedded_credentials": {"username": "u", "password": "p"}, + "sslmode": "require", + } + ), + ) + params: dict[str, Any] = {} + + database.update_params_from_encrypted_extra(params) + + assert "oauth2_client_info" not in params + assert "embedded_credentials" not in params + assert params.get("sslmode") == "require" + + +@pytest.mark.parametrize("guest", [True, False]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_rejected_fallback_credential_is_logged_for_admins( + mocker: MockerFixture, guest: bool +) -> None: + """ + Test that only a guest's fallback engine logs a rejected sign-in. + + Viewers only see a generic error, so the log is how an administrator finds a + wrong password or key. It names the connection and the exception class, never + the driver's message, which can carry the username. + """ + mocker.patch("superset.models.core._ENGINE_CACHE", {}) + mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite:////nonexistent/dir/embedded.db"), + ) + if guest: + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + warning = mocker.patch("superset.models.core.logger.warning") + + engine = _embedded_snowflake_database()._get_sqla_engine(nullpool=True) + with pytest.raises(sqlalchemy.exc.OperationalError): + engine.connect() + + rejections = [call for call in warning.call_args_list if "rejected" in call.args[0]] + if guest: + assert len(rejections) == 1 + assert rejections[0].args[3] == "OperationalError" + rendered = rejections[0].args[0] % rejections[0].args[1:] + assert "embedded_pw" not in rendered + assert "embedded_svc" not in rendered + else: + assert rejections == [] + + +@pytest.mark.parametrize("guest", [True, False]) +@with_feature_flags(EMBEDDED_CREDENTIAL_FALLBACK=True, EMBEDDED_SUPERSET=True) +def test_a_rejected_fallback_credential_is_logged_on_the_raw_connection_path( + mocker: MockerFixture, guest: bool +) -> None: + """ + Chart data on a physical table connects through ``get_raw_connection`` + (sc-127710). SQLAlchemy 2 doesn't dispatch ``handle_error`` for a failed + ``raw_connection()``, so that path must log the rejection itself, once. + """ + mocker.patch("superset.models.core._ENGINE_CACHE", {}) + mocker.patch( + "superset.models.core.create_engine", + return_value=create_engine("sqlite:////nonexistent/dir/embedded.db"), + ) + if guest: + _become_guest(mocker) + _stub_driver_dependent_uri_validation(mocker) + warning = mocker.patch("superset.models.core.logger.warning") + + with pytest.raises(Exception, match="unable to open database file"): + with _embedded_snowflake_database().get_raw_connection(): + pass + + rejections = [call for call in warning.call_args_list if "rejected" in call.args[0]] + if guest: + assert len(rejections) == 1 + assert rejections[0].args[1] == "snowflake_db" + assert rejections[0].args[3] == "OperationalError" + else: + assert rejections == [] + + +def test_a_rejection_reported_by_two_paths_is_logged_once( + mocker: MockerFixture, +) -> None: + """The engine event and the raw path can both see one failure; log it once.""" + import sqlite3 + + from superset.models.core import _log_embedded_rejection + + warning = mocker.patch("superset.models.core.logger.warning") + original = sqlite3.OperationalError("unable to open database file") + wrapped = sqlalchemy.exc.OperationalError("connect", {}, original) + + _log_embedded_rejection(12, "snowflake_db", original, wrapped) + _log_embedded_rejection(12, "snowflake_db", original) + _log_embedded_rejection(12, "snowflake_db", wrapped) + + warning.assert_called_once() + assert warning.call_args.args[3] == "OperationalError" diff --git a/tests/unit_tests/security/inner_rls_cache_key_test.py b/tests/unit_tests/security/inner_rls_cache_key_test.py index bd8acecf6901..da8793201c0f 100644 --- a/tests/unit_tests/security/inner_rls_cache_key_test.py +++ b/tests/unit_tests/security/inner_rls_cache_key_test.py @@ -872,3 +872,23 @@ def keys() -> list[Any]: assert keys() == ["rls-predicate-parse-failed-for-user-alice"] viewer.name = "bob" assert keys() == ["rls-predicate-parse-failed-for-user-bob"] + + +def test_embedded_credential_partitions_column_values_cache_key( + app_context: None, mocker: Any +) -> None: + """A guest's filter values never share an entry with a logged-in user's.""" + from superset.connectors.sqla.models import SqlaTable + from superset.models.core import Database + + database = Database(database_name="db", sqlalchemy_uri="sqlite://") + datasource = SqlaTable(table_name="t", database=database, columns=[]) + logged_in = _key(datasource) + + mocker.patch.object( + Database, + "get_embedded_fallback_credentials", + return_value={"username": "svc", "password": "pw"}, + ) + + assert _key(datasource) != logged_in diff --git a/tests/unit_tests/utils/json_tests.py b/tests/unit_tests/utils/json_tests.py index 660c9020443e..5390d6e1727a 100644 --- a/tests/unit_tests/utils/json_tests.py +++ b/tests/unit_tests/utils/json_tests.py @@ -450,3 +450,45 @@ def test_dumps_preserves_unicode_when_ensure_ascii_false() -> None: assert json.dumps("Hello, world!", ensure_ascii=False) == '"Hello, world!"' assert json.dumps("Привет, мир!", ensure_ascii=False) == '"Привет, мир!"' assert json.dumps("你好,世界!", ensure_ascii=False) == '"你好,世界!"' + + +def test_redact_sensitive_leaves_empty_values_unmasked() -> None: + """An empty secret has nothing to hide, and must not read as a stored one.""" + payload = {"credential": {"username": "", "password": "", "token": None}} + + assert ( + json.redact_sensitive( + payload, + {"$.credential.password", "$.credential.token"}, + ) + == payload + ) + assert json.redact_sensitive( + {"credential": {"password": "pw"}}, {"$.credential.password"} + ) == {"credential": {"password": PASSWORD_MASK}} + + +@pytest.mark.parametrize( + "old_payload,expected", + [ + (None, ["$.secret", "$.other"]), + ({"secret": "stored"}, ["$.other"]), + ({"secret": "stored", "other": ""}, ["$.other"]), + ({"secret": "stored", "other": "also"}, []), + # A stored mask is what master saved when it had nothing to reveal. + ({"secret": PASSWORD_MASK, "other": "also"}, ["$.secret"]), + # reveal_sensitive restores any stored value, falsy ones included. + ({"secret": 0, "other": False}, []), + ], +) +def test_unrevealable_masked_fields( + old_payload: dict[str, Any] | None, expected: list[str] +) -> None: + """Masked fields the stored payload has no (non-empty) value for.""" + new_payload = {"secret": PASSWORD_MASK, "other": PASSWORD_MASK, "plain": "x"} + + assert sorted( + json.unrevealable_masked_fields( + old_payload, new_payload, {"$.secret", "$.other", "$.plain"} + ) + ) == sorted(expected) diff --git a/tests/unit_tests/utils/oauth2_tests.py b/tests/unit_tests/utils/oauth2_tests.py index 0421feb14ac2..239fd113b4aa 100644 --- a/tests/unit_tests/utils/oauth2_tests.py +++ b/tests/unit_tests/utils/oauth2_tests.py @@ -40,6 +40,7 @@ OAuth2RedirectError, OAuth2TokenRefreshError, ) +from superset.security.guest_token import GuestToken, GuestUser from superset.superset_typing import OAuth2ClientConfig from superset.utils.oauth2 import ( check_for_oauth2, @@ -55,6 +56,7 @@ OAUTH2_LOCK_BACKOFF_MAX_TRIES, refresh_oauth2_token, ) +from tests.unit_tests.conftest import with_feature_flags DUMMY_OAUTH2_CONFIG = cast(OAuth2ClientConfig, {}) @@ -1097,3 +1099,89 @@ def test_execute_with_oauth2_retry_survives_lock_contention( assert lock.call_count == OAUTH2_LOCK_BACKOFF_MAX_TRIES db.session.query().filter_by().one_or_none.assert_called() + + +def _guest_user() -> GuestUser: + """ + Build an embedded guest principal, the way the guest-token loader would. + """ + token: GuestToken = { + "user": {"username": "guest_user"}, + "resources": [], + "rls_rules": [], + "iat": 0, + "exp": 1, + } + return GuestUser(token=token, roles=[]) + + +@with_feature_flags(EMBEDDED_SUPERSET=True) +def test_check_for_oauth2_starts_the_dance_for_a_logged_in_user( + mocker: MockerFixture, +) -> None: + """ + Test that `check_for_oauth2` still starts the dance for a real user. + """ + mocker.patch("superset.utils.oauth2.g").user = mocker.MagicMock( + spec=["id", "username"] + ) + + database = mocker.MagicMock() + database.is_oauth2_enabled.return_value = True + + with pytest.raises(OAuth2TokenRefreshError): + with check_for_oauth2(database): + raise OAuth2TokenRefreshError() + + database.db_engine_spec.start_oauth2_dance.assert_called_once_with(database) + + +@with_feature_flags(EMBEDDED_SUPERSET=True) +def test_check_for_oauth2_skips_the_dance_for_an_embedded_guest( + mocker: MockerFixture, +) -> None: + """ + Test that an embedded guest does not trigger the dance from `check_for_oauth2`. + + The `OAuth2TokenRefreshError` arm of the condition reaches the dance without + consulting `needs_oauth2`, so the guest check has to be repeated here. Without it + an embedded viewer reaches `start_oauth2_dance`, which needs a user id the guest + token does not carry. + """ + mocker.patch("superset.utils.oauth2.g").user = _guest_user() + + database = mocker.MagicMock() + database.is_oauth2_enabled.return_value = True + + with pytest.raises(OAuth2TokenRefreshError): + with check_for_oauth2(database): + raise OAuth2TokenRefreshError() + + database.db_engine_spec.start_oauth2_dance.assert_not_called() + + +@with_feature_flags(EMBEDDED_SUPERSET=True) +def test_execute_with_oauth2_retry_skips_the_dance_for_an_embedded_guest( + mocker: MockerFixture, +) -> None: + """ + Test that a guest gets the driver's error out of `execute_with_oauth2_retry`. + + This passes on the strength of the `needs_oauth2` guard alone -- every arm of the + retry sits behind `is_oauth2_error`. It is here to pin that ordering, which is + easy to lose in a refactor of the surrounding branches. + """ + mocker.patch("superset.utils.oauth2.g").user = _guest_user() + mocker.patch("superset.utils.oauth2.db") + + database = mocker.MagicMock() + database.is_oauth2_enabled.return_value = True + database.db_engine_spec.needs_oauth2.return_value = False + + def operation() -> None: + raise OAuth2TokenRefreshError() + + with pytest.raises(OAuth2TokenRefreshError): + execute_with_oauth2_retry(database, operation) + + database.start_oauth2_dance.assert_not_called() diff --git a/tests/unit_tests/utils/test_impersonation_cache_key.py b/tests/unit_tests/utils/test_impersonation_cache_key.py index 6203dd37d486..bf3d535457e1 100644 --- a/tests/unit_tests/utils/test_impersonation_cache_key.py +++ b/tests/unit_tests/utils/test_impersonation_cache_key.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch from flask_appbuilder.security.sqla.models import User @@ -105,3 +105,186 @@ def test_no_user_yields_no_key(app_context): # noqa: ARG001 ) # No override_user — g.user is unset assert "impersonation_key" not in _run(database) + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_embedded_fallback_keys_the_cache_without_any_caching_flag(feature_flag_mock): + """ + The embedded fallback gets its own cache identity with no opt-in flag set. + + It replaces the connection identity without going through impersonation, so + none of the conditions below would notice it, and its rows must never land on + the same DATA-cache entry -- nor the same SHARED async task, whose key is the + query cache key -- as a logged-in user's per-user OAuth2 result. + """ + feature_flag_mock.is_feature_enabled.return_value = False + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + with patch.object( + Database, + "get_embedded_fallback_credentials", + return_value={"username": "embedded_svc", "password": "pw"}, + ): + assert _run(database)["embedded_credentials_key"] == "embedded:embedded_svc" + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_embedded_fallback_key_differs_from_a_logged_in_user(feature_flag_mock): + """ + A guest and a logged-in user must not compute the same key for one query. + """ + feature_flag_mock.is_feature_enabled.side_effect = _flag("CACHE_QUERY_BY_USER") + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + + with patch.object( + Database, + "get_embedded_fallback_credentials", + return_value={"username": "embedded_svc", "password": "pw"}, + ): + guest_key = _run(database) + + with override_user(User(username="embedded_svc")): + user_key = _run(database) + + # Even a logged-in account that happens to share the credential's name -- + # or a guest token claiming it -- must not collide with the guest entry. + assert guest_key != user_key + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_embedded_fallback_ignores_the_guest_token_username_claim(feature_flag_mock): + """ + The key comes from the configured credential, not from ``g.user``. + + Whoever mints the guest token controls its ``username`` claim; letting that + into the key would let a token holder choose which cache entry they read. + """ + feature_flag_mock.is_feature_enabled.side_effect = _flag("CACHE_QUERY_BY_USER") + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + + with patch.object( + Database, + "get_embedded_fallback_credentials", + return_value={"username": "embedded_svc", "password": "pw"}, + ): + with override_user(User(username="ACCOUNTADMIN")): + key_a = _run(database)["embedded_credentials_key"] + with override_user(User(username="someone_else")): + key_b = _run(database)["embedded_credentials_key"] + + assert key_a == key_b == "embedded:embedded_svc" + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_query_object_cache_key_separates_guest_from_logged_in(feature_flag_mock): + """ + The separation survives all the way to the hashed ``QueryObject.cache_key``. + + That key is also the SHARED async ``task_key``, so without this a guest and a + logged-in user would dedup onto one task and one DATA-cache entry for the same + chart -- whichever of them arrived first choosing the credential for both. + """ + from superset.common.query_object import QueryObject + + feature_flag_mock.is_feature_enabled.side_effect = _flag("CACHE_QUERY_BY_USER") + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + + def key_for(credentials: dict[str, str] | None) -> str: + query_object = QueryObject(columns=["region"], metrics=["count"]) + # `QueryObject.cache_key` reaches the database through its datasource; the + # rest of the datasource plays no part in this assertion. + query_object.datasource = Mock(database=database, uid="1__table") + with patch.object( + Database, + "get_embedded_fallback_credentials", + return_value=credentials, + ): + return query_object.cache_key() + + guest_key = key_for({"username": "embedded_svc", "password": "pw"}) + with override_user(User(username="embedded:embedded_svc")): + logged_in_key = key_for(None) + + assert guest_key != logged_in_key + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_the_engine_spec_owns_the_embedded_cache_key(feature_flag_mock): + """ + The key material comes from the engine spec, not from this helper. + + An engine whose fallback credential has no ``username`` -- or whose cached rows + depend on more of the credential than the identity does -- states that in + ``get_embedded_credential_cache_key`` rather than having this helper guess. + """ + from superset.db_engine_specs.sqlite import SqliteEngineSpec + + class CustomEngineSpec(SqliteEngineSpec): + @classmethod + def get_embedded_credential_cache_key(cls, credentials: dict[str, str]) -> str: + return f"tenant:{credentials['tenant']}" + + feature_flag_mock.is_feature_enabled.return_value = False + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + + with ( + patch.object( + Database, + "get_embedded_fallback_credentials", + return_value={"tenant": "acme"}, + ), + patch.object( + Database, + "db_engine_spec", + new_callable=lambda: property(lambda self: CustomEngineSpec), + ), + ): + assert _run(database)["embedded_credentials_key"] == "tenant:acme" + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_unconfigured_embedded_guest_cannot_read_a_warm_logged_in_cache( + feature_flag_mock: Mock, +) -> None: + """Missing fallback credentials get a separate result and async task key.""" + from superset.common.query_object import QueryObject + + feature_flag_mock.is_feature_enabled.return_value = False + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + query_object = QueryObject(columns=["region"], metrics=["count"]) + query_object.datasource = Mock(database=database, uid="1__table") + with patch.object(Database, "get_embedded_fallback_credentials", return_value=None): + with patch.object(Database, "embedded_fallback_applies", return_value=False): + logged_in_key = query_object.cache_key() + warmed_cache = {logged_in_key: ["logged-in rows"]} + with patch.object(Database, "embedded_fallback_applies", return_value=True): + guest_key = query_object.cache_key() + assert _run(database) == {"embedded_credentials_missing": True} + assert guest_key not in warmed_cache + with patch.object( + Database, + "get_embedded_fallback_credentials", + return_value={"username": "missing", "password": "pw"}, + ): + configured_key = query_object.cache_key() + assert len({guest_key, logged_in_key, configured_key}) == 3 + + +@patch("superset.utils.cache_keys.feature_flag_manager") +def test_a_guest_refused_for_a_missing_oauth2_client_gets_a_separate_cache_key( + feature_flag_mock: Mock, +) -> None: + """ + A guest refused on a promoted connection cannot read a warm logged-in entry. + + The refusal happens at engine creation, which a cache hit never reaches. + """ + feature_flag_mock.is_feature_enabled.return_value = False + database = Database(database_name="d", sqlalchemy_uri="sqlite://") + with ( + patch.object(Database, "get_embedded_fallback_credentials", return_value=None), + patch.object(Database, "embedded_fallback_applies", return_value=False), + patch.object( + Database, "embedded_guest_missing_oauth2_client", return_value=True + ), + ): + assert _run(database) == {"embedded_credentials_missing": True}