mirror of https://github.com/apache/superset.git
feat: return security errors in the SIP-40 format (#9796)
This commit is contained in:
parent
cf30e16550
commit
d02f2d1fa7
|
@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue