hughhhh commented on code in PR #45890:
URL: https://github.com/apache/superset/pull/45890#discussion_r4457311870


##########
superset/commands/dashboard/importers/v1/__init__.py:
##########
@@ -79,14 +79,24 @@ 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
 
     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

Review Comment:
   > [!NOTE]
   > 🤖 **Claude Opus 5.5 responding on behalf of Hugh (hughhhh)**
   
   Fixed in `6d02100f3f`. `validate_masked_encrypted_extra` now also checks 
`can_write` on Database next to `overwrites_existing`, so validation and 
`import_database` make the same decision. This sits in the schema, so it covers 
both the dashboard and `/api/v1/assets/import/` paths.
   
   `test_imports_without_database_write_leave_the_connection_alone` covers both 
schemas with an unchanged and a changed URI, and fails if the check is removed. 
I tested at the schema and `import_database` level, not with a live Alpha login.
   
   The 500 itself is separate: `_on_import_error` turns every asset validation 
failure into `ImportFailedError`. A user who can write databases still gets 
that 500, not a prompt, when an asset import needs a secret. I left that 
unchanged.



##########
superset/databases/schemas.py:
##########
@@ -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(

Review Comment:
   > [!NOTE]
   > 🤖 **Claude Opus 5.5 responding on behalf of Hugh (hughhhh)**
   
   Fixed in `6d02100f3f`. A stored `oauth2_client_info` no longer counts as 
revealable when the file names a different `id`, and the same goes for 
`embedded_credentials` with a different `username`, including its key-pair 
secrets. Each case now gets the usual prompt.
   
   The check is in the shared schema, so it also changes database-file imports, 
which is noted in `UPDATING.md`. A hand-written block with no `id` or 
`username` still reveals, because there is nothing to compare. Tests: 
`test_import_schema_asks_for_a_secret_when_the_file_names_someone_else` and 
`test_import_schema_reveals_a_secret_for_the_same_identity`.
   
   Also fixed here, for your +1 on the `json.py:395` note: a stored `None` or 
empty string now counts as revealable, and only a stored mask counts as missing 
(`test_import_schema_reveals_a_stored_null_secret`).



##########
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"] = (

Review Comment:
   > [!NOTE]
   > 🤖 **Claude Opus 5.5 responding on behalf of Hugh (hughhhh)**
   
   Fixed in `6d02100f3f`. `_annotation_cache_context` now adds the source 
connection's embedded identity as `source_embedded_identity`, for `SqlaTable` 
and `Query` sources. Swapping the credential changes the key, and clearing it 
gives the missing-credential marker, so a warm entry can't skip the refusal.
   
   The material comes from a new `embedded_guest_cache_key` helper rather than 
`add_impersonation_cache_key_if_needed`, and the component is left out when 
empty, so non-guest keys don't change. Semantic view sources aren't covered, 
because they don't have a `Database` connection here. Test: 
`test_annotation_cache_key_binds_the_source_connection_embedded_identity`.



##########
docs/sip/embedded-fallback-credential.md:
##########
@@ -0,0 +1,291 @@
+<!--
+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.
+-->
+
+# 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 is refused instead. So is a 
guest on
+a connection with impersonation enabled but no OAuth2 client at all: on 
Snowflake,
+impersonation without OAuth2 never changed the login, so such a connection 
served
+guests under its base account, and with the flag on it stops serving them 
until an
+administrator either turns impersonation off or configures an OAuth2 client 
and an
+embedded credential. The refusal is scoped to the flag so that a deployment 
which has
+not opted in keeps its existing behaviour.
+
+The refusal reaches only guests, whose error responses are redacted to a 
generic
+message unless Superset authored the error type. It is therefore raised as a
+`DATABASE_SECURITY_ACCESS_ERROR` with a fixed message that names nothing about 
the
+connection, so the viewer learns the chart's connection isn't set up for 
embedded
+dashboards. The administrator's explanation, naming the connection and the 
fix, goes
+to the server log. Under `GLOBAL_ASYNC_QUERIES` the task keeps only an untyped 
message,
+so the viewer gets the generic error instead; the server log is the same.
+
+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:<configured username>`) 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/` and on `validate_parameters` while both 
embedding flags
+  are enabled. Import is deliberately not tightened, so existing exports still 
load.
+
+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,

Review Comment:
   > [!NOTE]
   > 🤖 **Claude Opus 5.5 responding on behalf of Hugh (hughhhh)**
   
   Fixed in `6d02100f3f`. The SIP's compatibility section now states the one 
change that applies whatever the flag, and tells operators to upgrade import 
destinations first, matching `UPDATING.md`. I also removed the round-trip claim 
from the kill-switch paragraph, and the interface list now describes 
`validated_embedded_credentials()` as a helper in `superset/databases/utils.py`.



##########
superset-frontend/src/features/databases/DatabaseModal/index.tsx:
##########
@@ -1799,7 +1834,8 @@ const DatabaseModal: 
FunctionComponent<DatabaseModalProps> = ({
             id={`encrypted_extra_${field.path}`}
             name={`encrypted_extra_${field.path}`}
             required
-            visibilityToggle
+            visibilityToggle={!isPrivateKeyField(field.path)}
+            renderAsTextArea={isPrivateKeyField(field.path)}

Review Comment:
   > [!NOTE]
   > 🤖 **Claude Opus 5.5 responding on behalf of Hugh (hughhhh)**
   
   Added in `6d02100f3f`: `keeps the newlines of a private key asked for by an 
import` in `DatabaseModal/index.test.tsx`. It imports a bundle asking for 
`$.embedded_credentials.privatekey_body` and `privatekey_pass`, pastes a 
multi-line PEM, and asserts a textarea for the key, a password input for the 
passphrase, and unchanged newlines in the submitted `encrypted_extra_secrets`. 
It fails if `renderAsTextArea` is removed.



##########
superset/db_engine_specs/snowflake.py:
##########
@@ -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)

Review Comment:
   > [!NOTE]
   > 🤖 **Claude Opus 5.5 responding on behalf of Hugh (hughhhh)**
   
   Yes, fixed in `6d02100f3f`. `apply_embedded_credentials` now catches 
`ValueError`, `TypeError` and `UnsupportedAlgorithm` from `_load_private_key` 
and goes through the same refusal as a missing credential, so the viewer gets 
the fixed message with a 400. The admin gets a warning naming the connection, 
the exception class and the fix. Only the class is logged, never the library's 
message or any key material.
   
   To call it from the engine spec, I renamed `_refuse_embedded_guest` to 
`refuse_embedded_guest`. Test: 
`test_embedded_keypair_that_cannot_load_refuses_the_guest`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to