feat: return security errors in the SIP-40 format (#9796)

This commit is contained in:
Erik Ritter 2020-05-13 17:10:37 -07:00 committed by GitHub
parent cf30e16550
commit d02f2d1fa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 123 additions and 21 deletions

View File

@ -49,7 +49,7 @@ describe('getClientErrorObject()', () => {
errors: [ errors: [
{ {
errorType: ErrorTypeEnum.GENERIC_DB_ENGINE_ERROR, errorType: ErrorTypeEnum.GENERIC_DB_ENGINE_ERROR,
extra: { engine: 'presto' }, extra: { engine: 'presto', link: 'https://www.google.com' },
level: 'error', level: 'error',
message: 'presto error: test error', message: 'presto error: test error',
}, },
@ -60,6 +60,7 @@ describe('getClientErrorObject()', () => {
return getClientErrorObject(new Response(jsonErrorString)).then( return getClientErrorObject(new Response(jsonErrorString)).then(
errorObj => { errorObj => {
expect(errorObj.error).toEqual(jsonError.errors[0].message); expect(errorObj.error).toEqual(jsonError.errors[0].message);
expect(errorObj.link).toEqual(jsonError.errors[0].extra.link);
}, },
); );
}); });

View File

@ -19,13 +19,24 @@
// Keep in sync with superset/views/errors.py // Keep in sync with superset/views/errors.py
export const ErrorTypeEnum = { export const ErrorTypeEnum = {
// Frontend errors
FRONTEND_CSRF_ERROR: 'FRONTEND_CSRF_ERROR', FRONTEND_CSRF_ERROR: 'FRONTEND_CSRF_ERROR',
FRONTEND_NETWORK_ERROR: 'FRONTEND_NETWORK_ERROR', FRONTEND_NETWORK_ERROR: 'FRONTEND_NETWORK_ERROR',
FRONTEND_TIMEOUT_ERROR: 'FRONTEND_TIMEOUT_ERROR', FRONTEND_TIMEOUT_ERROR: 'FRONTEND_TIMEOUT_ERROR',
// DB Engine errors
GENERIC_DB_ENGINE_ERROR: 'GENERIC_DB_ENGINE_ERROR', GENERIC_DB_ENGINE_ERROR: 'GENERIC_DB_ENGINE_ERROR',
// Viz errors
VIZ_GET_DF_ERROR: 'VIZ_GET_DF_ERROR', VIZ_GET_DF_ERROR: 'VIZ_GET_DF_ERROR',
UNKNOWN_DATASOURCE_TYPE_ERROR: 'UNKNOWN_DATASOURCE_TYPE_ERROR',
FAILED_FETCHING_DATASOURCE_INFO_ERROR:
'FAILED_FETCHING_DATASOURCE_INFO_ERROR',
// Security access errors
TABLE_SECURITY_ACCESS_ERROR: 'TABLE_SECURITY_ACCESS_ERROR',
DATASOURCE_SECURITY_ACCESS_ERROR: 'DATASOURCE_SECURITY_ACCESS_ERROR',
MISSING_OWNERSHIP_ERROR: 'MISSING_OWNERSHIP_ERROR',
} as const; } as const;
type ValueOf<T> = T[keyof T]; type ValueOf<T> = T[keyof T];

View File

@ -26,8 +26,9 @@ import COMMON_ERR_MESSAGES from './errorMessages';
export type ClientErrorObject = { export type ClientErrorObject = {
error: string; error: string;
errors?: SupersetError[]; errors?: SupersetError[];
severity?: string; link?: string;
message?: string; message?: string;
severity?: string;
stacktrace?: string; stacktrace?: string;
} & Partial<SupersetClientResponse>; } & Partial<SupersetClientResponse>;
@ -54,6 +55,7 @@ export default function getClientErrorObject(
// Backwards compatibility for old error renderers with the new error object // Backwards compatibility for old error renderers with the new error object
if (error.errors && error.errors.length > 0) { if (error.errors && error.errors.length > 0) {
error.error = error.description = error.errors[0].message; error.error = error.description = error.errors[0].message;
error.link = error.errors[0]?.extra?.link;
} }
if (error.stack) { if (error.stack) {

View File

@ -14,7 +14,7 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods,invalid-name
from enum import Enum from enum import Enum
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@ -28,13 +28,23 @@ class SupersetErrorType(str, Enum):
Keep in sync with superset-frontend/src/components/ErrorMessage/types.ts Keep in sync with superset-frontend/src/components/ErrorMessage/types.ts
""" """
# Frontend errors
FRONTEND_CSRF_ERROR = "FRONTEND_CSRF_ERROR" FRONTEND_CSRF_ERROR = "FRONTEND_CSRF_ERROR"
FRONTEND_NETWORK_ERROR = "FRONTEND_NETWORK_ERROR" FRONTEND_NETWORK_ERROR = "FRONTEND_NETWORK_ERROR"
FRONTEND_TIMEOUT_ERROR = "FRONTEND_TIMEOUT_ERROR" FRONTEND_TIMEOUT_ERROR = "FRONTEND_TIMEOUT_ERROR"
# DB Engine errors
GENERIC_DB_ENGINE_ERROR = "GENERIC_DB_ENGINE_ERROR" GENERIC_DB_ENGINE_ERROR = "GENERIC_DB_ENGINE_ERROR"
# Viz errors
VIZ_GET_DF_ERROR = "VIZ_GET_DF_ERROR" VIZ_GET_DF_ERROR = "VIZ_GET_DF_ERROR"
UNKNOWN_DATASOURCE_TYPE_ERROR = "UNKNOWN_DATASOURCE_TYPE_ERROR"
FAILED_FETCHING_DATASOURCE_INFO_ERROR = "FAILED_FETCHING_DATASOURCE_INFO_ERROR"
# Security access errors
TABLE_SECURITY_ACCESS_ERROR = "TABLE_SECURITY_ACCESS_ERROR"
DATASOURCE_SECURITY_ACCESS_ERROR = "DATASOURCE_SECURITY_ACCESS_ERROR"
MISSING_OWNERSHIP_ERROR = "MISSING_OWNERSHIP_ERROR"
class ErrorLevel(str, Enum): class ErrorLevel(str, Enum):

View File

@ -14,10 +14,12 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
from typing import Optional from typing import Any, Dict, Optional
from flask_babel import gettext as _ from flask_babel import gettext as _
from superset.errors import SupersetError
class SupersetException(Exception): class SupersetException(Exception):
status = 500 status = 500
@ -41,9 +43,12 @@ class SupersetTimeoutException(SupersetException):
class SupersetSecurityException(SupersetException): class SupersetSecurityException(SupersetException):
status = 401 status = 401
def __init__(self, msg: str, link: Optional[str] = None) -> None: def __init__(
super(SupersetSecurityException, self).__init__(msg) self, error: SupersetError, payload: Optional[Dict[str, Any]] = None
self.link = link ) -> None:
super(SupersetSecurityException, self).__init__(error.message)
self.error = error
self.payload = payload
class NoDataException(SupersetException): class NoDataException(SupersetException):

View File

@ -43,6 +43,7 @@ from sqlalchemy.orm.query import Query
from superset import sql_parse from superset import sql_parse
from superset.connectors.connector_registry import ConnectorRegistry from superset.connectors.connector_registry import ConnectorRegistry
from superset.constants import RouteMethod from superset.constants import RouteMethod
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException from superset.exceptions import SupersetSecurityException
from superset.utils.core import DatasourceName from superset.utils.core import DatasourceName
@ -291,6 +292,25 @@ class SupersetSecurityManager(SecurityManager):
return conf.get("PERMISSION_INSTRUCTIONS_LINK") return conf.get("PERMISSION_INSTRUCTIONS_LINK")
def get_datasource_access_error_object(
self, datasource: "BaseDatasource"
) -> SupersetError:
"""
Return the error object for the denied Superset datasource.
:param datasource: The denied Superset datasource
:returns: The error object
"""
return SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message=self.get_datasource_access_error_msg(datasource),
level=ErrorLevel.ERROR,
extra={
"link": self.get_datasource_access_link(datasource),
"datasource": datasource.name,
},
)
def get_table_access_error_msg(self, tables: Set["Table"]) -> str: def get_table_access_error_msg(self, tables: Set["Table"]) -> str:
""" """
Return the error message for the denied SQL tables. Return the error message for the denied SQL tables.
@ -303,6 +323,23 @@ class SupersetSecurityManager(SecurityManager):
return f"""You need access to the following tables: {", ".join(quoted_tables)}, return f"""You need access to the following tables: {", ".join(quoted_tables)},
`all_database_access` or `all_datasource_access` permission""" `all_database_access` or `all_datasource_access` permission"""
def get_table_access_error_object(self, tables: Set["Table"]) -> SupersetError:
"""
Return the error object for the denied SQL tables.
:param tables: The set of denied SQL tables
:returns: The error object
"""
return SupersetError(
error_type=SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
message=self.get_table_access_error_msg(tables),
level=ErrorLevel.ERROR,
extra={
"link": self.get_table_access_link(tables),
"tables": [str(table) for table in tables],
},
)
def get_table_access_link(self, tables: Set["Table"]) -> Optional[str]: def get_table_access_link(self, tables: Set["Table"]) -> Optional[str]:
""" """
Return the access link for the denied SQL tables. Return the access link for the denied SQL tables.
@ -828,8 +865,7 @@ class SupersetSecurityManager(SecurityManager):
if not self.datasource_access(datasource): if not self.datasource_access(datasource):
raise SupersetSecurityException( raise SupersetSecurityException(
self.get_datasource_access_error_msg(datasource), self.get_datasource_access_error_object(datasource),
self.get_datasource_access_link(datasource),
) )
def assert_query_context_permission(self, query_context: "QueryContext") -> None: def assert_query_context_permission(self, query_context: "QueryContext") -> None:

View File

@ -20,6 +20,7 @@ import traceback
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import dataclasses
import simplejson as json import simplejson as json
import yaml import yaml
from flask import abort, flash, g, get_flashed_messages, redirect, Response, session from flask import abort, flash, g, get_flashed_messages, redirect, Response, session
@ -44,6 +45,7 @@ from superset import (
security_manager, security_manager,
) )
from superset.connectors.sqla import models from superset.connectors.sqla import models
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetException, SupersetSecurityException from superset.exceptions import SupersetException, SupersetSecurityException
from superset.translations.utils import get_language_pack from superset.translations.utils import get_language_pack
from superset.utils import core as utils from superset.utils import core as utils
@ -81,7 +83,7 @@ def get_error_msg() -> str:
def json_error_response( def json_error_response(
msg: Optional[str] = None, msg: Optional[str] = None,
status: int = 500, status: int = 500,
payload: Optional[dict] = None, payload: Optional[Dict[str, Any]] = None,
link: Optional[str] = None, link: Optional[str] = None,
) -> Response: ) -> Response:
if not payload: if not payload:
@ -96,6 +98,22 @@ def json_error_response(
) )
def json_errors_response(
errors: List[SupersetError],
status: int = 500,
payload: Optional[Dict[str, Any]] = None,
) -> Response:
if not payload:
payload = {}
payload["errors"] = [dataclasses.asdict(error) for error in errors]
return Response(
json.dumps(payload, default=utils.json_iso_dttm_ser, ignore_nan=True),
status=status,
mimetype="application/json",
)
def json_success(json_msg: str, status: int = 200) -> Response: def json_success(json_msg: str, status: int = 200) -> Response:
return Response(json_msg, status=status, mimetype="application/json") return Response(json_msg, status=status, mimetype="application/json")
@ -142,8 +160,8 @@ def handle_api_exception(f):
return f(self, *args, **kwargs) return f(self, *args, **kwargs)
except SupersetSecurityException as ex: except SupersetSecurityException as ex:
logger.exception(ex) logger.exception(ex)
return json_error_response( return json_errors_response(
utils.error_msg_from_exception(ex), status=ex.status, link=ex.link errors=[ex.error], status=ex.status, payload=ex.payload
) )
except SupersetException as ex: except SupersetException as ex:
logger.exception(ex) logger.exception(ex)
@ -432,7 +450,11 @@ def check_ownership(obj: Any, raise_if_false: bool = True) -> bool:
return False return False
security_exception = SupersetSecurityException( security_exception = SupersetSecurityException(
"You don't have the rights to alter [{}]".format(obj) SupersetError(
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
message="You don't have the rights to alter [{}]".format(obj),
level=ErrorLevel.ERROR,
)
) )
if g.user.is_anonymous: if g.user.is_anonymous:

View File

@ -66,6 +66,7 @@ from superset import (
from superset.connectors.connector_registry import ConnectorRegistry from superset.connectors.connector_registry import ConnectorRegistry
from superset.connectors.sqla.models import AnnotationDatasource from superset.connectors.sqla.models import AnnotationDatasource
from superset.constants import RouteMethod from superset.constants import RouteMethod
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import ( from superset.exceptions import (
CertificateException, CertificateException,
DatabaseNotFound, DatabaseNotFound,
@ -108,6 +109,7 @@ from .base import (
get_user_roles, get_user_roles,
handle_api_exception, handle_api_exception,
json_error_response, json_error_response,
json_errors_response,
json_success, json_success,
SupersetModelView, SupersetModelView,
validate_sqlatable, validate_sqlatable,
@ -189,10 +191,22 @@ def check_datasource_perms(
datasource_id, datasource_type, form_data datasource_id, datasource_type, form_data
) )
except SupersetException as ex: except SupersetException as ex:
raise SupersetSecurityException(str(ex)) raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.FAILED_FETCHING_DATASOURCE_INFO_ERROR,
level=ErrorLevel.ERROR,
message=str(ex),
)
)
if datasource_type is None: if datasource_type is None:
raise SupersetSecurityException("Could not determine datasource type") raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.UNKNOWN_DATASOURCE_TYPE_ERROR,
level=ErrorLevel.ERROR,
message="Could not determine datasource type",
)
)
viz_obj = get_viz( viz_obj = get_viz(
datasource_type=datasource_type, datasource_type=datasource_type,
@ -2187,8 +2201,9 @@ class Superset(BaseSupersetView):
query.sql, query.database, query.schema query.sql, query.database, query.schema
) )
if rejected_tables: if rejected_tables:
return json_error_response( return json_errors_response(
security_manager.get_table_access_error_msg(rejected_tables), status=403 [security_manager.get_table_access_error_object(rejected_tables)],
status=403,
) )
payload = utils.zlib_decompress(blob, decode=not results_backend_use_msgpack) payload = utils.zlib_decompress(blob, decode=not results_backend_use_msgpack)
@ -2491,9 +2506,8 @@ class Superset(BaseSupersetView):
if rejected_tables: if rejected_tables:
query.status = QueryStatus.FAILED query.status = QueryStatus.FAILED
session.commit() session.commit()
return json_error_response( return json_errors_response(
security_manager.get_table_access_error_msg(rejected_tables), [security_manager.get_table_access_error_object(rejected_tables)],
link=security_manager.get_table_access_link(rejected_tables),
status=403, status=403,
) )

View File

@ -951,7 +951,8 @@ class CoreTests(SupersetTestCase):
data = self.get_json_resp("/superset/explore_json/", raise_on_error=False) data = self.get_json_resp("/superset/explore_json/", raise_on_error=False)
self.assertEqual( self.assertEqual(
data["error"], "The datasource associated with this chart no longer exists" data["errors"][0]["message"],
"The datasource associated with this chart no longer exists",
) )
@mock.patch("superset.security.SupersetSecurityManager.schemas_accessible_by_user") @mock.patch("superset.security.SupersetSecurityManager.schemas_accessible_by_user")