feat: Adding encrypted field factory (#14109)

* First cut at adding enc type factory

* Finalized enc type factory

* Adding unit test

* PyLinting

* Adding license

* Apply suggestions from code review

Co-authored-by: Jesse Yang <jesse.yang@airbnb.com>

* Moving things from enc -> encrypt

* CI commit

* One more fix

* Tweaking config name

* Fixing broken test

* Fixing broken test again

Co-authored-by: Jesse Yang <jesse.yang@airbnb.com>
This commit is contained in:
Craig Rueda 2021-04-16 09:01:18 -07:00 committed by GitHub
parent 7e0e9ac3fc
commit a49e0b2037
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 163 additions and 20 deletions

View File

@ -81,7 +81,7 @@ confidence=
# --enable=similarities". If you want to run only the classes checker, but have # --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes # no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W" # --disable=W"
disable=long-builtin,dict-view-method,intern-builtin,suppressed-message,no-absolute-import,unpacking-in-except,apply-builtin,delslice-method,indexing-exception,old-raise-syntax,print-statement,cmp-builtin,reduce-builtin,useless-suppression,coerce-method,input-builtin,cmp-method,raw_input-builtin,nonzero-method,backtick,basestring-builtin,setslice-method,reload-builtin,oct-method,map-builtin-not-iterating,execfile-builtin,old-octal-literal,zip-builtin-not-iterating,buffer-builtin,getslice-method,metaclass-assignment,xrange-builtin,long-suffix,round-builtin,range-builtin-not-iterating,next-method-called,parameter-unpacking,unicode-builtin,unichr-builtin,import-star-module-level,raising-string,filter-builtin-not-iterating,using-cmp-argument,coerce-builtin,file-builtin,old-division,hex-method,missing-docstring,too-many-lines,ungrouped-imports,import-outside-toplevel,raise-missing-from,super-with-arguments,bad-option-value disable=long-builtin,dict-view-method,intern-builtin,suppressed-message,no-absolute-import,unpacking-in-except,apply-builtin,delslice-method,indexing-exception,old-raise-syntax,print-statement,cmp-builtin,reduce-builtin,useless-suppression,coerce-method,input-builtin,cmp-method,raw_input-builtin,nonzero-method,backtick,basestring-builtin,setslice-method,reload-builtin,oct-method,map-builtin-not-iterating,execfile-builtin,old-octal-literal,zip-builtin-not-iterating,buffer-builtin,getslice-method,metaclass-assignment,xrange-builtin,long-suffix,round-builtin,range-builtin-not-iterating,next-method-called,parameter-unpacking,unicode-builtin,unichr-builtin,import-star-module-level,raising-string,filter-builtin-not-iterating,using-cmp-argument,coerce-builtin,file-builtin,old-division,hex-method,missing-docstring,too-many-lines,ungrouped-imports,import-outside-toplevel,raise-missing-from,super-with-arguments,bad-option-value,too-few-public-methods
[REPORTS] [REPORTS]

View File

@ -35,6 +35,7 @@ from superset.extensions import (
celery_app, celery_app,
csrf, csrf,
db, db,
encrypted_field_factory,
feature_flag_manager, feature_flag_manager,
machine_auth_provider_factory, machine_auth_provider_factory,
manifest_processor, manifest_processor,
@ -75,6 +76,7 @@ class SupersetIndexView(IndexView):
return redirect("/superset/welcome/") return redirect("/superset/welcome/")
# pylint: disable=R0904
class SupersetAppInitializer: class SupersetAppInitializer:
def __init__(self, app: Flask) -> None: def __init__(self, app: Flask) -> None:
super().__init__() super().__init__()
@ -551,6 +553,7 @@ class SupersetAppInitializer:
self.pre_init() self.pre_init()
# Configuration of logging must be done first to apply the formatter properly # Configuration of logging must be done first to apply the formatter properly
self.configure_logging() self.configure_logging()
self.configure_db_encrypt()
self.setup_db() self.setup_db()
self.configure_celery() self.configure_celery()
self.setup_event_logger() self.setup_event_logger()
@ -670,6 +673,9 @@ class SupersetAppInitializer:
self.config, self.flask_app.debug self.config, self.flask_app.debug
) )
def configure_db_encrypt(self) -> None:
encrypted_field_factory.init_app(self.flask_app)
def setup_db(self) -> None: def setup_db(self) -> None:
db.init_app(self.flask_app) db.init_app(self.flask_app)

View File

@ -45,6 +45,7 @@ from superset.jinja_context import ( # pylint: disable=unused-import
from superset.stats_logger import DummyStatsLogger from superset.stats_logger import DummyStatsLogger
from superset.typing import CacheConfig from superset.typing import CacheConfig
from superset.utils.core import is_test from superset.utils.core import is_test
from superset.utils.encrypt import SQLAlchemyUtilsAdapter
from superset.utils.log import DBEventLogger from superset.utils.log import DBEventLogger
from superset.utils.logging_configurator import DefaultLoggingConfigurator from superset.utils.logging_configurator import DefaultLoggingConfigurator
@ -172,6 +173,17 @@ SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "superset.db")
# SQLALCHEMY_CUSTOM_PASSWORD_STORE = lookup_password # SQLALCHEMY_CUSTOM_PASSWORD_STORE = lookup_password
SQLALCHEMY_CUSTOM_PASSWORD_STORE = None SQLALCHEMY_CUSTOM_PASSWORD_STORE = None
#
# The EncryptedFieldTypeAdapter is used whenever we're building SqlAlchemy models
# which include sensitive fields that should be app-encrypted BEFORE sending
# to the DB.
#
# Note: the default impl leverages SqlAlchemyUtils' EncryptedType, which defaults
# to AES-128 under the covers using the app's SECRET_KEY as key material.
#
# pylint: disable=C0103
SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER = SQLAlchemyUtilsAdapter
# The limit of queries fetched for query search # The limit of queries fetched for query search
QUERY_SEARCH_LIMIT = 1000 QUERY_SEARCH_LIMIT = 1000

View File

@ -47,12 +47,12 @@ from sqlalchemy import (
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import backref, relationship, Session from sqlalchemy.orm import backref, relationship, Session
from sqlalchemy.sql import expression from sqlalchemy.sql import expression
from sqlalchemy_utils import EncryptedType
from superset import conf, db, is_feature_enabled, security_manager from superset import conf, db, security_manager
from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric
from superset.constants import NULL_STRING from superset.constants import NULL_STRING
from superset.exceptions import SupersetException from superset.exceptions import SupersetException
from superset.extensions import encrypted_field_factory
from superset.models.core import Database from superset.models.core import Database
from superset.models.helpers import AuditMixinNullable, ImportExportMixin, QueryResult from superset.models.helpers import AuditMixinNullable, ImportExportMixin, QueryResult
from superset.typing import FilterValues, Granularity, Metric, QueryObjectDict from superset.typing import FilterValues, Granularity, Metric, QueryObjectDict
@ -138,7 +138,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportExportMixin):
metadata_last_refreshed = Column(DateTime) metadata_last_refreshed = Column(DateTime)
cache_timeout = Column(Integer) cache_timeout = Column(Integer)
broker_user = Column(String(255)) broker_user = Column(String(255))
broker_pass = Column(EncryptedType(String(255), conf.get("SECRET_KEY"))) broker_pass = Column(encrypted_field_factory.create(String(255)))
export_fields = [ export_fields = [
"cluster_name", "cluster_name",

View File

@ -29,6 +29,7 @@ from werkzeug.local import LocalProxy
from superset.utils.async_query_manager import AsyncQueryManager from superset.utils.async_query_manager import AsyncQueryManager
from superset.utils.cache_manager import CacheManager from superset.utils.cache_manager import CacheManager
from superset.utils.encrypt import EncryptedFieldFactory
from superset.utils.feature_flag_manager import FeatureFlagManager from superset.utils.feature_flag_manager import FeatureFlagManager
from superset.utils.machine_auth import MachineAuthProviderFactory from superset.utils.machine_auth import MachineAuthProviderFactory
@ -104,6 +105,7 @@ celery_app = celery.Celery()
csrf = CSRFProtect() csrf = CSRFProtect()
db = SQLA() db = SQLA()
_event_logger: Dict[str, Any] = {} _event_logger: Dict[str, Any] = {}
encrypted_field_factory = EncryptedFieldFactory()
event_logger = LocalProxy(lambda: _event_logger.get("event_logger")) event_logger = LocalProxy(lambda: _event_logger.get("event_logger"))
feature_flag_manager = FeatureFlagManager() feature_flag_manager = FeatureFlagManager()
machine_auth_provider_factory = MachineAuthProviderFactory() machine_auth_provider_factory = MachineAuthProviderFactory()

View File

@ -24,7 +24,6 @@ Create Date: 2015-11-21 11:18:00.650587
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
from sqlalchemy_utils import EncryptedType
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "289ce07647b" revision = "289ce07647b"
@ -32,9 +31,7 @@ down_revision = "2929af7925ed"
def upgrade(): def upgrade():
op.add_column( op.add_column("dbs", sa.Column("password", sa.LargeBinary(), nullable=True))
"dbs", sa.Column("password", EncryptedType(sa.String(1024)), nullable=True)
)
def downgrade(): def downgrade():

View File

@ -30,15 +30,13 @@ from typing import Dict
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
from sqlalchemy_utils import EncryptedType
def upgrade(): def upgrade():
kwargs: Dict[str, str] = {} kwargs: Dict[str, str] = {}
bind = op.get_bind() bind = op.get_bind()
op.add_column( op.add_column(
"dbs", "dbs", sa.Column("server_cert", sa.LargeBinary(), nullable=True, **kwargs),
sa.Column("server_cert", EncryptedType(sa.Text()), nullable=True, **kwargs),
) )

View File

@ -39,7 +39,7 @@ def upgrade():
batch_op.alter_column( batch_op.alter_column(
"encrypted_extra", "encrypted_extra",
existing_type=sa.Text(), existing_type=sa.Text(),
type_=EncryptedType(sa.Text()), type_=sa.LargeBinary(),
postgresql_using="encrypted_extra::bytea", postgresql_using="encrypted_extra::bytea",
existing_nullable=True, existing_nullable=True,
) )
@ -49,7 +49,7 @@ def upgrade():
"dbs", "dbs",
"encrypted_extra", "encrypted_extra",
existing_type=sa.Text(), existing_type=sa.Text(),
type_=EncryptedType(sa.Text()), type_=sa.LargeBinary(),
existing_nullable=True, existing_nullable=True,
) )
@ -58,7 +58,7 @@ def downgrade():
with op.batch_alter_table("dbs") as batch_op: with op.batch_alter_table("dbs") as batch_op:
batch_op.alter_column( batch_op.alter_column(
"encrypted_extra", "encrypted_extra",
existing_type=EncryptedType(sa.Text()), existing_type=sa.LargeBinary(),
type_=sa.Text(), type_=sa.Text(),
existing_nullable=True, existing_nullable=True,
) )

View File

@ -33,7 +33,7 @@ from sqlalchemy_utils import EncryptedType
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column("clusters", sa.Column("broker_pass", EncryptedType(), nullable=True)) op.add_column("clusters", sa.Column("broker_pass", sa.LargeBinary(), nullable=True))
op.add_column( op.add_column(
"clusters", sa.Column("broker_user", sa.String(length=255), nullable=True) "clusters", sa.Column("broker_user", sa.String(length=255), nullable=True)
) )

View File

@ -52,11 +52,10 @@ from sqlalchemy.orm import relationship
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from sqlalchemy.schema import UniqueConstraint from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import expression, Select from sqlalchemy.sql import expression, Select
from sqlalchemy_utils import EncryptedType
from superset import app, db_engine_specs, is_feature_enabled from superset import app, db_engine_specs, is_feature_enabled
from superset.db_engine_specs.base import TimeGrain from superset.db_engine_specs.base import TimeGrain
from superset.extensions import cache_manager, security_manager from superset.extensions import cache_manager, encrypted_field_factory, security_manager
from superset.models.helpers import AuditMixinNullable, ImportExportMixin from superset.models.helpers import AuditMixinNullable, ImportExportMixin
from superset.models.tags import FavStarUpdater from superset.models.tags import FavStarUpdater
from superset.result_set import SupersetResultSet from superset.result_set import SupersetResultSet
@ -115,7 +114,7 @@ class Database(
# short unique name, used in permissions # short unique name, used in permissions
database_name = Column(String(250), unique=True, nullable=False) database_name = Column(String(250), unique=True, nullable=False)
sqlalchemy_uri = Column(String(1024), nullable=False) sqlalchemy_uri = Column(String(1024), nullable=False)
password = Column(EncryptedType(String(1024), config["SECRET_KEY"])) password = Column(encrypted_field_factory.create(String(1024)))
cache_timeout = Column(Integer) cache_timeout = Column(Integer)
select_as_create_table_as = Column(Boolean, default=False) select_as_create_table_as = Column(Boolean, default=False)
expose_in_sqllab = Column(Boolean, default=True) expose_in_sqllab = Column(Boolean, default=True)
@ -141,9 +140,9 @@ class Database(
""" """
), ),
) )
encrypted_extra = Column(EncryptedType(Text, config["SECRET_KEY"]), nullable=True) encrypted_extra = Column(encrypted_field_factory.create(Text), nullable=True)
impersonate_user = Column(Boolean, default=False) impersonate_user = Column(Boolean, default=False)
server_cert = Column(EncryptedType(Text, config["SECRET_KEY"]), nullable=True) server_cert = Column(encrypted_field_factory.create(Text), nullable=True)
export_fields = [ export_fields = [
"database_name", "database_name",
"sqlalchemy_uri", "sqlalchemy_uri",

66
superset/utils/encrypt.py Normal file
View File

@ -0,0 +1,66 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from flask import Flask
from sqlalchemy import TypeDecorator
from sqlalchemy_utils import EncryptedType
class AbstractEncryptedFieldAdapter(ABC):
@abstractmethod
def create(
self,
app_config: Optional[Dict[str, Any]],
*args: List[Any],
**kwargs: Optional[Dict[str, Any]]
) -> TypeDecorator:
pass
class SQLAlchemyUtilsAdapter(AbstractEncryptedFieldAdapter):
def create(
self,
app_config: Optional[Dict[str, Any]],
*args: List[Any],
**kwargs: Optional[Dict[str, Any]]
) -> TypeDecorator:
if app_config:
return EncryptedType(*args, app_config["SECRET_KEY"], **kwargs)
raise Exception("Missing app_config kwarg")
class EncryptedFieldFactory:
def __init__(self) -> None:
self._concrete_type_adapter: Optional[AbstractEncryptedFieldAdapter] = None
self._config: Optional[Dict[str, Any]] = None
def init_app(self, app: Flask) -> None:
self._config = app.config
self._concrete_type_adapter = self._config[
"SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER"
]()
def create(
self, *args: List[Any], **kwargs: Optional[Dict[str, Any]]
) -> TypeDecorator:
if self._concrete_type_adapter:
return self._concrete_type_adapter.create(self._config, *args, **kwargs)
raise Exception("App not initialized yet. Please call init_app first")

View File

@ -0,0 +1,63 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Dict, List, Optional
from sqlalchemy import String, TypeDecorator
from sqlalchemy_utils import EncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import StringEncryptedType
from superset.extensions import encrypted_field_factory
from superset.utils.encrypt import AbstractEncryptedFieldAdapter, SQLAlchemyUtilsAdapter
from tests.base_tests import SupersetTestCase
class CustomEncFieldAdapter(AbstractEncryptedFieldAdapter):
def create(
self,
app_config: Optional[Dict[str, Any]],
*args: List[Any],
**kwargs: Optional[Dict[str, Any]]
) -> TypeDecorator:
if app_config:
return StringEncryptedType(*args, app_config["SECRET_KEY"], **kwargs)
else:
raise Exception("Missing app_config kwarg")
class EncryptedFieldTest(SupersetTestCase):
def setUp(self) -> None:
self.app.config[
"SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER"
] = SQLAlchemyUtilsAdapter
encrypted_field_factory.init_app(self.app)
super().setUp()
def test_create_field(self):
field = encrypted_field_factory.create(String(1024))
self.assertTrue(isinstance(field, EncryptedType))
self.assertEqual(self.app.config["SECRET_KEY"], field.key)
def test_custom_adapter(self):
self.app.config[
"SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER"
] = CustomEncFieldAdapter
encrypted_field_factory.init_app(self.app)
field = encrypted_field_factory.create(String(1024))
self.assertTrue(isinstance(field, StringEncryptedType))
self.assertFalse(isinstance(field, EncryptedType))
self.assertEqual(self.app.config["SECRET_KEY"], field.key)