Files
superset2/superset/extensions/storage/persistent_state_dao.py
Michael S. Molina 53fcc21992 Separate configs
2026-04-10 16:49:14 -03:00

279 lines
10 KiB
Python

# 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 __future__ import annotations
import base64
import hashlib
import logging
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
from flask import current_app
from sqlalchemy import and_
from superset import db
from superset.extensions.storage.persistent_state_model import ExtensionStorage
logger = logging.getLogger(__name__)
def _key_to_fernet(raw_key: str | bytes) -> Fernet:
"""Derive a Fernet instance from an arbitrary-length secret string.
SHA-256 compresses the key to exactly 32 bytes, which are then
base64url-encoded to satisfy Fernet's key format requirement.
"""
if isinstance(raw_key, str):
raw_key = raw_key.encode()
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(raw_key).digest()))
def _fernet() -> MultiFernet:
"""Return a MultiFernet built from EXTENSIONS_PERSISTENT_STORAGE["ENCRYPTION_KEYS"].
Falls back to SECRET_KEY when the list is absent or empty. The first key in
the list is used for new encryptions; all keys are tried on decryption,
enabling zero-downtime rotation: add the new key at the front of
ENCRYPTION_KEYS, then run ``superset rotate-extension-storage-keys`` to
re-encrypt every row with the new key.
"""
persistent_cfg = current_app.config.get("EXTENSIONS_PERSISTENT_STORAGE", {})
raw_keys: list[str | bytes] = persistent_cfg.get("ENCRYPTION_KEYS") or [
current_app.config["SECRET_KEY"]
]
return MultiFernet([_key_to_fernet(k) for k in raw_keys])
def _scope_filter(
extension_id: str,
key: str,
user_fk: int | None = None,
resource_type: str | None = None,
resource_uuid: str | None = None,
) -> list[object]:
"""Build the SQLAlchemy filter list for a scoped lookup."""
filters: list[object] = [
ExtensionStorage.extension_id == extension_id,
ExtensionStorage.key == key,
]
if user_fk is None:
filters.append(ExtensionStorage.user_fk.is_(None))
else:
filters.append(ExtensionStorage.user_fk == user_fk)
if resource_type is None:
filters.append(ExtensionStorage.resource_type.is_(None))
else:
filters.append(ExtensionStorage.resource_type == resource_type)
if resource_uuid is None:
filters.append(ExtensionStorage.resource_uuid.is_(None))
else:
filters.append(ExtensionStorage.resource_uuid == resource_uuid)
return filters
class ExtensionStorageDAO:
"""Persistent key-value store for extensions.
Provides scoped get/set/delete and list operations covering the three
storage scopes defined by the Tier 3 proposal:
* Global scope — user_fk=None, resource_type=None
* User scope — user_fk=<id>, resource_type=None
* Resource scope — resource_type + resource_uuid set
"""
# ── Read ─────────────────────────────────────────────────────────────────
@staticmethod
def get(
extension_id: str,
key: str,
user_fk: int | None = None,
resource_type: str | None = None,
resource_uuid: str | None = None,
) -> ExtensionStorage | None:
"""Return the raw storage entry. The value field may be encrypted."""
entry = (
db.session.query(ExtensionStorage)
.filter(
and_(
*_scope_filter(
extension_id, key, user_fk, resource_type, resource_uuid
)
)
)
.first()
)
return entry
@staticmethod
def get_value(
extension_id: str,
key: str,
user_fk: int | None = None,
resource_type: str | None = None,
resource_uuid: str | None = None,
) -> bytes | None:
"""Return the raw (decrypted) value bytes, or None if not found."""
entry = ExtensionStorageDAO.get(
extension_id, key, user_fk, resource_type, resource_uuid
)
if entry is None:
return None
if entry.is_encrypted:
try:
return _fernet().decrypt(entry.value)
except InvalidToken:
logger.error(
"Failed to decrypt extension storage value for "
"extension_id=%s key=%s — possible key rotation issue",
extension_id,
key,
)
return None
return entry.value
# ── Write (upsert) ────────────────────────────────────────────────────────
@staticmethod
def set(
extension_id: str,
key: str,
value: bytes,
value_type: str = "application/json",
user_fk: int | None = None,
resource_type: str | None = None,
resource_uuid: str | None = None,
category: str | None = None,
description: str | None = None,
is_encrypted: bool = False,
) -> ExtensionStorage:
"""Upsert a storage entry. Encrypts value when is_encrypted=True."""
stored_value = _fernet().encrypt(value) if is_encrypted else value
entry = (
db.session.query(ExtensionStorage)
.filter(
and_(
*_scope_filter(
extension_id, key, user_fk, resource_type, resource_uuid
)
)
)
.first()
)
if entry is not None:
entry.value = stored_value
entry.value_type = value_type
entry.category = category
entry.description = description
entry.is_encrypted = is_encrypted
else:
entry = ExtensionStorage(
extension_id=extension_id,
key=key,
value=stored_value,
value_type=value_type,
user_fk=user_fk,
resource_type=resource_type,
resource_uuid=resource_uuid,
category=category,
description=description,
is_encrypted=is_encrypted,
)
db.session.add(entry)
db.session.flush()
return entry
# ── Delete ────────────────────────────────────────────────────────────────
@staticmethod
def delete(
extension_id: str,
key: str,
user_fk: int | None = None,
resource_type: str | None = None,
resource_uuid: str | None = None,
) -> bool:
"""Delete an entry. Returns True if a row was removed."""
entry = (
db.session.query(ExtensionStorage)
.filter(
and_(
*_scope_filter(
extension_id, key, user_fk, resource_type, resource_uuid
)
)
)
.first()
)
if entry is None:
return False
db.session.delete(entry)
db.session.flush()
return True
# ── List ──────────────────────────────────────────────────────────────────
@staticmethod
def list_global(
extension_id: str,
category: str | None = None,
) -> list[ExtensionStorage]:
"""List all global (user_fk=NULL, resource_type=NULL) entries."""
q = db.session.query(ExtensionStorage).filter(
ExtensionStorage.extension_id == extension_id,
ExtensionStorage.user_fk.is_(None),
ExtensionStorage.resource_type.is_(None),
)
if category is not None:
q = q.filter(ExtensionStorage.category == category)
return q.order_by(ExtensionStorage.key).all()
@staticmethod
def list_user(
extension_id: str,
user_fk: int,
category: str | None = None,
) -> list[ExtensionStorage]:
"""List all user-scoped entries (resource_type=NULL)."""
q = db.session.query(ExtensionStorage).filter(
ExtensionStorage.extension_id == extension_id,
ExtensionStorage.user_fk == user_fk,
ExtensionStorage.resource_type.is_(None),
)
if category is not None:
q = q.filter(ExtensionStorage.category == category)
return q.order_by(ExtensionStorage.key).all()
@staticmethod
def list_resource(
extension_id: str,
resource_type: str,
resource_uuid: str,
category: str | None = None,
) -> list[ExtensionStorage]:
"""List all entries linked to a specific resource."""
q = db.session.query(ExtensionStorage).filter(
ExtensionStorage.extension_id == extension_id,
ExtensionStorage.resource_type == resource_type,
ExtensionStorage.resource_uuid == resource_uuid,
)
if category is not None:
q = q.filter(ExtensionStorage.category == category)
return q.order_by(ExtensionStorage.key).all()