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,