feat(bigquery): Custom message when Service Account doesnt have the correct Roles and Permissions (#21838)

This commit is contained in:
Antonio Rivero Martinez 2022-10-26 20:44:09 -03:00 committed by GitHub
parent 059e53a39f
commit 203b289021
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 86 additions and 21 deletions

View File

@ -75,10 +75,12 @@ from superset.databases.schemas import (
from superset.databases.utils import get_table_metadata from superset.databases.utils import get_table_metadata
from superset.db_engine_specs import get_available_engine_specs from superset.db_engine_specs import get_available_engine_specs
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorsException
from superset.extensions import security_manager from superset.extensions import security_manager
from superset.models.core import Database from superset.models.core import Database
from superset.superset_typing import FlaskResponse from superset.superset_typing import FlaskResponse
from superset.utils.core import error_msg_from_exception, parse_js_uri_path_item from superset.utils.core import error_msg_from_exception, parse_js_uri_path_item
from superset.views.base import json_errors_response
from superset.views.base_api import ( from superset.views.base_api import (
BaseSupersetModelRestApi, BaseSupersetModelRestApi,
requires_form_data, requires_form_data,
@ -224,7 +226,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
log_to_statsd=False, log_to_statsd=False,
) )
@requires_json @requires_json
def post(self) -> Response: def post(self) -> FlaskResponse:
"""Creates a new Database """Creates a new Database
--- ---
post: post:
@ -281,6 +283,8 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
return self.response_422(message=ex.normalized_messages()) return self.response_422(message=ex.normalized_messages())
except DatabaseConnectionFailedError as ex: except DatabaseConnectionFailedError as ex:
return self.response_422(message=str(ex)) return self.response_422(message=str(ex))
except SupersetErrorsException as ex:
return json_errors_response(errors=ex.errors, status=ex.status)
except DatabaseCreateFailedError as ex: except DatabaseCreateFailedError as ex:
logger.error( logger.error(
"Error creating model %s: %s", "Error creating model %s: %s",

View File

@ -31,6 +31,7 @@ from superset.databases.commands.exceptions import (
) )
from superset.databases.commands.test_connection import TestConnectionDatabaseCommand from superset.databases.commands.test_connection import TestConnectionDatabaseCommand
from superset.databases.dao import DatabaseDAO from superset.databases.dao import DatabaseDAO
from superset.exceptions import SupersetErrorsException
from superset.extensions import db, event_logger, security_manager from superset.extensions import db, event_logger, security_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -46,6 +47,13 @@ class CreateDatabaseCommand(BaseCommand):
try: try:
# Test connection before starting create transaction # Test connection before starting create transaction
TestConnectionDatabaseCommand(self._properties).run() TestConnectionDatabaseCommand(self._properties).run()
except SupersetErrorsException as ex:
event_logger.log_with_context(
action=f"db_creation_failed.{ex.__class__.__name__}",
engine=self._properties.get("sqlalchemy_uri", "").split(":")[0],
)
# So we can show the original message
raise ex
except Exception as ex: except Exception as ex:
event_logger.log_with_context( event_logger.log_with_context(
action=f"db_creation_failed.{ex.__class__.__name__}", action=f"db_creation_failed.{ex.__class__.__name__}",

View File

@ -29,13 +29,16 @@ from superset.commands.base import BaseCommand
from superset.databases.commands.exceptions import ( from superset.databases.commands.exceptions import (
DatabaseSecurityUnsafeError, DatabaseSecurityUnsafeError,
DatabaseTestConnectionDriverError, DatabaseTestConnectionDriverError,
DatabaseTestConnectionFailedError,
DatabaseTestConnectionUnexpectedError, DatabaseTestConnectionUnexpectedError,
) )
from superset.databases.dao import DatabaseDAO from superset.databases.dao import DatabaseDAO
from superset.databases.utils import make_url_safe from superset.databases.utils import make_url_safe
from superset.errors import ErrorLevel, SupersetErrorType from superset.errors import ErrorLevel, SupersetErrorType
from superset.exceptions import SupersetSecurityException, SupersetTimeoutException from superset.exceptions import (
SupersetErrorsException,
SupersetSecurityException,
SupersetTimeoutException,
)
from superset.extensions import event_logger from superset.extensions import event_logger
from superset.models.core import Database from superset.models.core import Database
@ -49,6 +52,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
def run(self) -> None: # pylint: disable=too-many-statements def run(self) -> None: # pylint: disable=too-many-statements
self.validate() self.validate()
ex_str = ""
uri = self._properties.get("sqlalchemy_uri", "") uri = self._properties.get("sqlalchemy_uri", "")
if self._model and uri == self._model.safe_sqlalchemy_uri(): if self._model and uri == self._model.safe_sqlalchemy_uri():
uri = self._model.sqlalchemy_uri_decrypted uri = self._model.sqlalchemy_uri_decrypted
@ -117,10 +121,13 @@ class TestConnectionDatabaseCommand(BaseCommand):
level=ErrorLevel.ERROR, level=ErrorLevel.ERROR,
extra={"sqlalchemy_uri": database.sqlalchemy_uri}, extra={"sqlalchemy_uri": database.sqlalchemy_uri},
) from ex ) from ex
except Exception: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
alive = False alive = False
# So we stop losing the original message if any
ex_str = str(ex)
if not alive: if not alive:
raise DBAPIError(None, None, None) raise DBAPIError(ex_str or None, None, None)
# Log succesful connection test with engine # Log succesful connection test with engine
event_logger.log_with_context( event_logger.log_with_context(
@ -145,7 +152,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
) )
# check for custom errors (wrong username, wrong password, etc) # check for custom errors (wrong username, wrong password, etc)
errors = database.db_engine_spec.extract_errors(ex, context) errors = database.db_engine_spec.extract_errors(ex, context)
raise DatabaseTestConnectionFailedError(errors) from ex raise SupersetErrorsException(errors) from ex
except SupersetSecurityException as ex: except SupersetSecurityException as ex:
event_logger.log_with_context( event_logger.log_with_context(
action=f"test_connection_error.{ex.__class__.__name__}", action=f"test_connection_error.{ex.__class__.__name__}",

View File

@ -46,8 +46,8 @@ if TYPE_CHECKING:
CONNECTION_DATABASE_PERMISSIONS_REGEX = re.compile( CONNECTION_DATABASE_PERMISSIONS_REGEX = re.compile(
"Access Denied: Project User does not have bigquery.jobs.create " "Access Denied: Project (?P<project_name>.+?): User does not have "
+ "permission in project (?P<project>.+?)" + "bigquery.jobs.create permission in project (?P<project>.+?)"
) )
TABLE_DOES_NOT_EXIST_REGEX = re.compile( TABLE_DOES_NOT_EXIST_REGEX = re.compile(
@ -154,9 +154,12 @@ class BigQueryEngineSpec(BaseEngineSpec):
custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, Any]]] = { custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, Any]]] = {
CONNECTION_DATABASE_PERMISSIONS_REGEX: ( CONNECTION_DATABASE_PERMISSIONS_REGEX: (
__( __(
"We were unable to connect to your database. Please " "Unable to connect. Verify that the following roles are set "
"confirm that your service account has the Viewer " 'on the service account: "BigQuery Data Viewer", '
"and Job User roles on the project." '"BigQuery Metadata Viewer", "BigQuery Job User" '
"and the following permissions are set "
'"bigquery.readsessions.create", '
'"bigquery.readsessions.getData"'
), ),
SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR, SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
{}, {},

View File

@ -525,11 +525,55 @@ class TestDatabaseApi(SupersetTestCase):
self.login(username="admin") self.login(username="admin")
response = self.client.post(uri, json=database_data) response = self.client.post(uri, json=database_data)
response_data = json.loads(response.data.decode("utf-8")) response_data = json.loads(response.data.decode("utf-8"))
expected_response = { superset_error_mysql = SupersetError(
"message": "Connection failed, please check your connection settings" message='Either the username "superset" or the password is incorrect.',
error_type="CONNECTION_ACCESS_DENIED_ERROR",
level="error",
extra={
"engine_name": "MySQL",
"invalid": ["username", "password"],
"issue_codes": [
{
"code": 1014,
"message": (
"Issue 1014 - Either the username or the password is wrong."
),
},
{
"code": 1015,
"message": (
"Issue 1015 - Issue 1015 - Either the database is spelled incorrectly or does not exist."
),
},
],
},
)
superset_error_postgres = SupersetError(
message='The password provided for username "superset" is incorrect.',
error_type="CONNECTION_INVALID_PASSWORD_ERROR",
level="error",
extra={
"engine_name": "PostgreSQL",
"invalid": ["username", "password"],
"issue_codes": [
{
"code": 1013,
"message": (
"Issue 1013 - The password provided when connecting to a database is not valid."
),
}
],
},
)
expected_response_mysql = {"errors": [dataclasses.asdict(superset_error_mysql)]}
expected_response_postgres = {
"errors": [dataclasses.asdict(superset_error_postgres)]
} }
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 500)
self.assertEqual(response_data, expected_response) if example_db.backend == "mysql":
self.assertEqual(response_data, expected_response_mysql)
else:
self.assertEqual(response_data, expected_response_postgres)
def test_update_database(self): def test_update_database(self):
""" """
@ -1516,7 +1560,7 @@ class TestDatabaseApi(SupersetTestCase):
url = "api/v1/database/test_connection/" url = "api/v1/database/test_connection/"
rv = self.post_assert_metric(url, data, "test_connection") rv = self.post_assert_metric(url, data, "test_connection")
assert rv.status_code == 422 assert rv.status_code == 500
assert rv.headers["Content-Type"] == "application/json; charset=utf-8" assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_response = {"errors": [dataclasses.asdict(superset_error)]} expected_response = {"errors": [dataclasses.asdict(superset_error)]}

View File

@ -32,7 +32,6 @@ from superset.databases.commands.exceptions import (
DatabaseNotFoundError, DatabaseNotFoundError,
DatabaseSecurityUnsafeError, DatabaseSecurityUnsafeError,
DatabaseTestConnectionDriverError, DatabaseTestConnectionDriverError,
DatabaseTestConnectionFailedError,
DatabaseTestConnectionUnexpectedError, DatabaseTestConnectionUnexpectedError,
) )
from superset.databases.commands.export import ExportDatabasesCommand from superset.databases.commands.export import ExportDatabasesCommand
@ -683,7 +682,7 @@ class TestTestConnectionDatabaseCommand(SupersetTestCase):
json_payload = {"sqlalchemy_uri": db_uri} json_payload = {"sqlalchemy_uri": db_uri}
command_without_db_name = TestConnectionDatabaseCommand(json_payload) command_without_db_name = TestConnectionDatabaseCommand(json_payload)
with pytest.raises(DatabaseTestConnectionFailedError) as excinfo: with pytest.raises(SupersetErrorsException) as excinfo:
command_without_db_name.run() command_without_db_name.run()
assert ( assert (
excinfo.value.errors[0].error_type excinfo.value.errors[0].error_type
@ -757,7 +756,7 @@ class TestTestConnectionDatabaseCommand(SupersetTestCase):
json_payload = {"sqlalchemy_uri": db_uri} json_payload = {"sqlalchemy_uri": db_uri}
command_without_db_name = TestConnectionDatabaseCommand(json_payload) command_without_db_name = TestConnectionDatabaseCommand(json_payload)
with pytest.raises(DatabaseTestConnectionFailedError) as excinfo: with pytest.raises(SupersetErrorsException) as excinfo:
command_without_db_name.run() command_without_db_name.run()
assert str(excinfo.value) == ( assert str(excinfo.value) == (
"Connection failed, please check your connection settings" "Connection failed, please check your connection settings"

View File

@ -246,11 +246,11 @@ class TestBigQueryDbEngineSpec(TestDbEngineSpec):
) )
def test_extract_errors(self): def test_extract_errors(self):
msg = "403 POST https://bigquery.googleapis.com/bigquery/v2/projects/test-keel-310804/jobs?prettyPrint=false: Access Denied: Project User does not have bigquery.jobs.create permission in project profound-keel-310804" msg = "403 POST https://bigquery.googleapis.com/bigquery/v2/projects/test-keel-310804/jobs?prettyPrint=false: Access Denied: Project profound-keel-310804: User does not have bigquery.jobs.create permission in project profound-keel-310804"
result = BigQueryEngineSpec.extract_errors(Exception(msg)) result = BigQueryEngineSpec.extract_errors(Exception(msg))
assert result == [ assert result == [
SupersetError( SupersetError(
message="We were unable to connect to your database. Please confirm that your service account has the Viewer and Job User roles on the project.", message='Unable to connect. Verify that the following roles are set on the service account: "BigQuery Data Viewer", "BigQuery Metadata Viewer", "BigQuery Job User" and the following permissions are set "bigquery.readsessions.create", "bigquery.readsessions.getData"',
error_type=SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR, error_type=SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
level=ErrorLevel.ERROR, level=ErrorLevel.ERROR,
extra={ extra={