feat(SIP-85): OAuth2 for databases (#27631)

This commit is contained in:
Beto Dealmeida
2024-04-02 22:05:33 -04:00
committed by GitHub
parent fdc2dbe7db
commit 9022f5c519
46 changed files with 2080 additions and 44 deletions

View File

@@ -17,13 +17,13 @@
# pylint: disable=too-many-lines
import json
import logging
from datetime import datetime
from datetime import datetime, timedelta
from io import BytesIO
from typing import Any, cast, Optional
from zipfile import is_zipfile, ZipFile
from deprecation import deprecated
from flask import request, Response, send_file
from flask import make_response, render_template, request, Response, send_file
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
@@ -62,7 +62,7 @@ from superset.commands.importers.exceptions import (
)
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.daos.database import DatabaseDAO
from superset.daos.database import DatabaseDAO, DatabaseUserOAuth2TokensDAO
from superset.databases.decorators import check_datasource_access
from superset.databases.filters import DatabaseFilter, DatabaseUploadEnabledFilter
from superset.databases.schemas import (
@@ -78,6 +78,7 @@ from superset.databases.schemas import (
DatabaseTestConnectionSchema,
DatabaseValidateParametersSchema,
get_export_ids_schema,
OAuth2ProviderResponseSchema,
openapi_spec_methods_override,
SchemasResponseSchema,
SelectStarResponseSchema,
@@ -89,11 +90,12 @@ from superset.databases.schemas import (
from superset.databases.utils import get_table_metadata
from superset.db_engine_specs import get_available_engine_specs
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorsException, SupersetException
from superset.exceptions import OAuth2Error, SupersetErrorsException, SupersetException
from superset.extensions import security_manager
from superset.models.core import Database
from superset.superset_typing import FlaskResponse
from superset.utils.core import error_msg_from_exception, parse_js_uri_path_item
from superset.utils.oauth2 import decode_oauth2_state
from superset.utils.ssh_tunnel import mask_password_info
from superset.views.base import json_errors_response
from superset.views.base_api import (
@@ -106,6 +108,7 @@ from superset.views.base_api import (
logger = logging.getLogger(__name__)
# pylint: disable=too-many-public-methods
class DatabaseRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Database)
@@ -127,7 +130,9 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
"delete_ssh_tunnel",
"schemas_access_for_file_upload",
"get_connection",
"oauth2",
}
resource_name = "database"
class_permission_name = "Database"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -1050,6 +1055,98 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
except DatabaseNotFoundError:
return self.response_404()
@expose("/oauth2/", methods=["GET"])
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.oauth2",
log_to_statsd=True,
)
def oauth2(self) -> FlaskResponse:
"""
---
get:
summary: >-
Receive personal access tokens from OAuth2
description: ->
Receive and store personal access tokens from OAuth for user-level
authorization
parameters:
- in: query
name: state
schema:
type: string
- in: query
name: code
schema:
type: string
- in: query
name: scope
schema:
type: string
- in: query
name: error
schema:
type: string
responses:
200:
description: A dummy self-closing HTML page
content:
text/html:
schema:
type: string
400:
$ref: '#/components/responses/400'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
parameters = OAuth2ProviderResponseSchema().load(request.args)
if "error" in parameters:
raise OAuth2Error(parameters["error"])
# note that when decoding the state we will perform JWT validation, preventing a
# malicious payload that would insert a bogus database token, or delete an
# existing one.
state = decode_oauth2_state(parameters["state"])
# exchange code for access/refresh tokens
database = DatabaseDAO.find_by_id(state["database_id"])
if database is None:
return self.response_404()
token_response = database.db_engine_spec.get_oauth2_token(
parameters["code"],
state,
)
# delete old tokens
existing = DatabaseUserOAuth2TokensDAO.find_one_or_none(
user_id=state["user_id"],
database_id=state["database_id"],
)
if existing:
DatabaseUserOAuth2TokensDAO.delete([existing], commit=True)
# store tokens
expiration = datetime.now() + timedelta(seconds=token_response["expires_in"])
DatabaseUserOAuth2TokensDAO.create(
attributes={
"user_id": state["user_id"],
"database_id": state["database_id"],
"access_token": token_response["access_token"],
"access_token_expiration": expiration,
"refresh_token": token_response.get("refresh_token"),
},
commit=True,
)
# return blank page that closes itself
return make_response(
render_template("superset/oauth2.html", tab_id=state["tab_id"]),
200,
)
@expose("/export/", methods=("GET",))
@protect()
@safe