You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/29 17:30:51 UTC

(superset) branch master updated: fix(database): mask secrets in create and update responses (#44733)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new f6a7802c81d fix(database): mask secrets in create and update responses (#44733)
f6a7802c81d is described below

commit f6a7802c81dece9501d1f08d048f5da82913a70f
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Sep 30 03:30:44 2026 +1000

    fix(database): mask secrets in create and update responses (#44733)
---
 docs/developer_docs/api.mdx            |  8 +++
 superset/databases/api.py              | 12 +++++
 tests/unit_tests/databases/api_test.py | 93 ++++++++++++++++++++++++++++++++++
 3 files changed, 113 insertions(+)

diff --git a/docs/developer_docs/api.mdx b/docs/developer_docs/api.mdx
index 26a338f632e..a9d5f4dbb37 100644
--- a/docs/developer_docs/api.mdx
+++ b/docs/developer_docs/api.mdx
@@ -54,6 +54,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
 
 ---
 
+### Database connection secrets
+
+The create (`POST /api/v1/database/`) and update (`PUT /api/v1/database/{pk}`)
+responses mask sensitive fields in `result.masked_encrypted_extra` with `XXXXXXXXXX`,
+using the same engine-specific masking as `GET /api/v1/database/{pk}/connection`.
+The submitted secrets are still stored. Sending the masked values back in a later
+update preserves the stored secrets; send new values to replace them.
+
 ### API Endpoints
 
 #### Core Resources
diff --git a/superset/databases/api.py b/superset/databases/api.py
index abf537fc22b..0457882cba6 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -467,6 +467,12 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
             item["uuid"] = new_model.uuid
             # Return censored version for sqlalchemy URI
             item["sqlalchemy_uri"] = new_model.sqlalchemy_uri
+            if "masked_encrypted_extra" in item:
+                item["masked_encrypted_extra"] = (
+                    new_model.db_engine_spec.mask_encrypted_extra(
+                        item["masked_encrypted_extra"]
+                    )
+                )
             item["expose_in_sqllab"] = new_model.expose_in_sqllab
 
             # If parameters are available return them in the payload
@@ -566,6 +572,12 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
             changed_model = UpdateDatabaseCommand(pk, item).run()
             # Return censored version for sqlalchemy URI
             item["sqlalchemy_uri"] = changed_model.sqlalchemy_uri
+            if "masked_encrypted_extra" in item:
+                item["masked_encrypted_extra"] = (
+                    changed_model.db_engine_spec.mask_encrypted_extra(
+                        item["masked_encrypted_extra"]
+                    )
+                )
             if changed_model.parameters:
                 item["parameters"] = changed_model.parameters
             # Return SSH Tunnel and hide passwords if any
diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py
index 7bb92a4bf39..33e08e54ac9 100644
--- a/tests/unit_tests/databases/api_test.py
+++ b/tests/unit_tests/databases/api_test.py
@@ -28,6 +28,7 @@ from uuid import UUID
 import pytest
 import yaml
 from flask import current_app
+from flask.testing import FlaskClient
 from freezegun import freeze_time
 from pytest_mock import MockerFixture
 from sqlalchemy.orm.session import Session
@@ -122,6 +123,98 @@ def test_post_with_uuid(
     assert database.uuid == UUID("7c1b7880-a59d-47cd-8bf1-f1eb8d2863cb")
 
 
+@pytest.mark.parametrize("method", ["POST", "PUT"])
+@pytest.mark.parametrize("engine", ["bigquery", "gsheets"])
+@pytest.mark.parametrize("credential_type", ["oauth2", "service_account"])
+def test_write_response_masks_encrypted_extra(
+    mocker: MockerFixture,
+    session: Session,
+    client: FlaskClient,
+    full_api_access: None,
+    method: str,
+    engine: str,
+    credential_type: str,
+) -> None:
+    """Mask write responses without changing stored or round-tripped credentials."""
+    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")
+    # Keep the real commands and persistence, but avoid external connections and
+    # permission synchronization, which require a live database and user.
+    mocker.patch("superset.commands.database.create.TestConnectionDatabaseCommand.run")
+    mocker.patch("superset.commands.database.create.add_permissions")
+    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)
+    mocker.patch("sqlalchemy.engine.URL.get_driver_name", return_value=engine)
+
+    if credential_type == "oauth2":
+        credential_key = "oauth2_client_info"
+        sensitive_field = "secret"
+        credentials = {
+            "id": "test-client",
+            "secret": "test-client-secret",
+            "scope": "test-scope",
+            "authorization_request_uri": "https://example.com/authorize",
+            "token_request_uri": "https://example.com/token",
+        }
+    else:
+        credential_key = (
+            "credentials_info" if engine == "bigquery" else "service_account_info"
+        )
+        sensitive_field = "private_key"
+        credentials = {
+            "type": "service_account",
+            "project_id": "test-project",
+            "private_key": "test-private-key",
+        }
+    encrypted_extra = {credential_key: credentials}
+    payload = {
+        "database_name": "test_database",
+        "sqlalchemy_uri": f"{engine}://",
+        "masked_encrypted_extra": json.dumps(encrypted_extra),
+    }
+    url = "/api/v1/database/"
+    if method == "PUT":
+        database = Database(
+            database_name="test_database",
+            sqlalchemy_uri=f"{engine}://",
+            encrypted_extra="{}",
+        )
+        session.add(database)
+        session.commit()
+        url += str(database.id)
+
+    response = client.open(url, method=method, json=payload)
+    assert response.status_code == (201 if method == "POST" else 200)
+    database_id = response.json["id"]
+    session.expire_all()
+    stored = session.get(Database, database_id)
+    assert stored is not None
+    assert json.loads(stored.encrypted_extra) == encrypted_extra
+
+    masked_extra = response.json["result"]["masked_encrypted_extra"]
+    assert json.loads(masked_extra) == {
+        credential_key: {**credentials, sensitive_field: "XXXXXXXXXX"}
+    }
+    assert credentials[sensitive_field] not in response.get_data(as_text=True)
+
+    # Saving the masked response must preserve the original secret.
+    response = client.put(
+        f"/api/v1/database/{database_id}",
+        json={"masked_encrypted_extra": masked_extra},
+    )
+    assert response.status_code == 200
+    assert json.loads(response.json["result"]["masked_encrypted_extra"]) == json.loads(
+        masked_extra
+    )
+    session.expire_all()
+    assert json.loads(stored.encrypted_extra) == encrypted_extra
+
+
 def test_password_mask(
     mocker: MockerFixture,
     app: Any,