Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ repos:
BASE=$(git merge-base origin/"$TARGET_BRANCH" HEAD 2>/dev/null) || BASE="HEAD"
files=$(git diff --name-only --diff-filter=ACM "$BASE"..HEAD 2>/dev/null | grep '^superset/.*\.py$' || true)
if [ -n "$files" ]; then
pylint --rcfile=.pylintrc --load-plugins=superset.extensions.pylint --reports=no $files
PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$(pwd)" pylint --rcfile=.pylintrc --load-plugins=superset.extensions.pylint --reports=no $files
else
echo "No Python files to lint."
fi
Expand Down
30 changes: 30 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ The Deck.gl MapBox chart's **Opacity**, **Default longitude**, **Default latitud

**To restore fit-to-data behavior:** Open the chart in Explore, clear the **Default longitude**, **Default latitude**, and **Zoom** fields in the Viewport section, and re-save the chart.

### Tier-3 Extension Storage: `ExtensionStorage` model + `ExtensionStorageDAO`

A generic `extension_storage` table now provides extensions with scoped,
encrypted persistent key-value storage without requiring extensions to
contribute model code to core Superset.

Three storage scopes are supported:

| Scope | Description |
|----------|-------------|
| **Global** | Shared across all users of an extension |
| **User** | Isolated per authenticated user |
| **Resource** | Tied to a specific Superset resource (dashboard, chart, …) by type + UUID |

REST API (all endpoints under `/api/v1/extensions/{publisher}/{name}/`):

```
GET/PUT/DELETE .../storage/persistent/global/{key}
GET .../storage/persistent/global/ (paginated)
GET/PUT/DELETE .../storage/persistent/user/{key}
GET .../storage/persistent/user/ (paginated, ?category=…)
GET/PUT/DELETE .../storage/persistent/resources/{type}/{uuid}/{key}
GET .../storage/persistent/resources/{type}/{uuid}/ (paginated)
```

Values may be stored encrypted at rest by including `"is_encrypted": true` in
the PUT body. The DAO uses a Fernet key derived from the app `SECRET_KEY`.
Key rotation is supported via `EXTENSION_STORAGE_ENCRYPTION_KEYS` in config
and the `superset rotate-extension-storage-keys` CLI command.

### ClickHouse minimum driver version bump

The minimum required version of `clickhouse-connect` has been raised to `>=0.13.0`. If you are using the ClickHouse connector, please upgrade your `clickhouse-connect` package. The `_mutate_label` workaround that appended hash suffixes to column aliases has also been removed, as it is no longer needed with modern versions of the driver.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
---
title: Persistent Storage
sidebar_position: 3
---

<!--
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.
-->

# Persistent Storage (Tier 3)

Extensions can store arbitrary key-value data in Superset's database without
contributing model code to the core codebase. The storage layer supports three
**scopes**, optional **category** filtering for cheap listing, optional
**at-rest encryption**, and **key rotation** with zero downtime.

## Storage Scopes

| Scope | When to use | `user_fk` | `resource_type` |
|---|---|---|---|
| **Global** | Configuration shared across all users of the extension | `null` | `null` |
| **User** | Per-user preferences or saved artefacts | set | `null` |
| **Resource** | State tied to a specific Superset resource (dashboard, chart, …) | any | set |

## REST API

All endpoints live under:

```
/api/v1/extensions/{publisher}/{name}/storage/persistent/
```

### Global scope

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `.../global/` | List entries (`?page=0&page_size=25&category=…`) |
| `GET` | `.../global/{key}` | Read a value (response body is the raw bytes) |
| `PUT` | `.../global/{key}` | Create or update |
| `DELETE` | `.../global/{key}` | Delete |

### User scope

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `.../user/` | List the authenticated user's entries |
| `GET` | `.../user/{key}` | Read a value |
| `PUT` | `.../user/{key}` | Create or update |
| `DELETE` | `.../user/{key}` | Delete |

### Resource scope

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `.../resources/{type}/{uuid}/` | List entries for the resource |
| `GET` | `.../resources/{type}/{uuid}/{key}` | Read a value |
| `PUT` | `.../resources/{type}/{uuid}/{key}` | Create or update |
| `DELETE` | `.../resources/{type}/{uuid}/{key}` | Delete |

## PUT request body

All write endpoints accept a JSON body:

```json
{
"value": "<string or base64 bytes>",
"value_type": "application/json",
"category": "my-category",
Comment on lines +79 to +83

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs claim value can be a “string or base64 bytes”, but the current REST API implementation stores string values as UTF-8 bytes without base64 decoding. Update the docs to match the actual accepted format (or update the API to decode base64 and validate errors) so extensions don’t inadvertently persist encoded text instead of raw bytes.

Copilot uses AI. Check for mistakes.
"description": "Human-readable label for cheap listing",
"is_encrypted": false
}
```

| Field | Required | Default | Notes |
|---|---|---|---|
| `value` | yes | `""` | The payload to store |
| `value_type` | no | `application/json` | MIME type returned on GET |
| `category` | no | `null` | Used to filter list results |
| `description` | no | `null` | Returned in list results without fetching `value` |
| `is_encrypted` | no | `false` | Encrypts `value` at rest with Fernet |

## List response

```json
{
"count": 42,
"result": [
{
"key": "my-key",
"uuid": "...",
"value_type": "application/json",
"category": "my-category",
"description": "...",
"is_encrypted": false
}
]
}
```

The `value` blob is **not** included in list responses. Fetch individual keys
to retrieve values.

## Frontend usage

From a TypeScript extension frontend, use standard `fetch` with the Superset
CSRF token:

```typescript
const BASE = '/api/v1/extensions/acme/my-ext/storage/persistent/user';

// Save
await fetch(`${BASE}/my-key`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': await authentication.getCSRFToken(),
},
body: JSON.stringify({
value: JSON.stringify({ foo: 'bar' }),
value_type: 'application/json',
category: 'preferences',
description: 'My display name', // shown in list without fetching value
}),
});

// List
const { result } = await fetch(`${BASE}/?category=preferences`).then(r => r.json());

// Read
const raw = await fetch(`${BASE}/my-key`).then(r => r.text());
const data = JSON.parse(raw);

// Delete
await fetch(`${BASE}/my-key`, { method: 'DELETE', headers: { 'X-CSRFToken': ... } });
```

## Backend usage (Python)

Within extension backend code, use `ExtensionStorageDAO` directly:

```python
from superset_core.common.daos import ExtensionStorageDAO

EXT_ID = "acme.my-ext"

# Write
ExtensionStorageDAO.set(EXT_ID, "config", b'{"theme":"dark"}', category="global-config")

# Read
value: bytes | None = ExtensionStorageDAO.get_value(EXT_ID, "config")

# User-scoped
ExtensionStorageDAO.set(EXT_ID, "prefs", b'{"lang":"fr"}', user_fk=user_id)
value = ExtensionStorageDAO.get_value(EXT_ID, "prefs", user_fk=user_id)

# Resource-scoped
ExtensionStorageDAO.set(
EXT_ID, "state", payload,
resource_type="dashboard", resource_uuid=str(dashboard.uuid),
)
```

:::note
`ExtensionStorageDAO` methods call `db.session.flush()` but do **not** commit.
The caller (or the `@transaction()` decorator on the API endpoint) owns the
transaction.
:::

## Encryption at rest

Set `"is_encrypted": true` in the PUT body (frontend) or `is_encrypted=True`
in `ExtensionStorageDAO.set()` (backend). Values are encrypted with
[Fernet](https://cryptography.io/en/latest/fernet/) using a key derived from
`SECRET_KEY` (or the first entry of `EXTENSION_STORAGE_ENCRYPTION_KEYS` if
configured). Decryption is transparent on read.

### Key rotation

To rotate encryption keys without downtime:

1. **Prepend** the new key to `EXTENSION_STORAGE_ENCRYPTION_KEYS` in your
Superset config and **restart**. Existing encrypted rows are still readable
(old key is tried as a fallback); new writes use the new key.

```python
# superset_config.py
EXTENSION_STORAGE_ENCRYPTION_KEYS = [
"new-strong-secret", # used for new encryptions
"old-secret", # still tried for decryption
]
```

2. **Re-encrypt** all existing rows with the new key:

```bash
superset rotate-extension-storage-keys
```

3. **Remove** the old key from the list and restart again.

## Data model

The `extension_storage` table schema:

| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `uuid` | UUID | Unique, auto-generated |
| `extension_id` | varchar(255) | `{publisher}.{name}` |
| `user_fk` | integer FK | `null` = global or resource scope |
| `resource_type` | varchar(64) | e.g. `dashboard`, `chart` |
| `resource_uuid` | varchar(36) | UUID of the linked resource |
| `key` | varchar(255) | Identifier within a scope |
| `category` | varchar(64) | Optional grouping tag |
| `description` | text | Summary for list endpoints |
| `value` | binary (up to 16 MB) | The stored payload |
| `value_type` | varchar(255) | MIME type, default `application/json` |
| `is_encrypted` | boolean | Whether `value` is Fernet-encrypted |
| `created_on`, `changed_on` | datetime | Audit fields |
| `created_by_fk`, `changed_by_fk` | integer FK | Audit fields |
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ multi_line_output = 3
order_by_type = false

[tool.mypy]
python_version = "3.10"
check_untyped_defs = true
disallow_any_generics = true
disallow_untyped_calls = true
Expand Down
48 changes: 48 additions & 0 deletions superset/cli/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,54 @@ def update_api_docs() -> None:
click.secho("API version not found", err=True)


@click.command()
@with_appcontext
@transaction()
def rotate_extension_storage_keys() -> None:
"""Re-encrypt all encrypted extension storage rows with the current key.

Run this after prepending a new key to EXTENSION_STORAGE_ENCRYPTION_KEYS
in your Superset config. MultiFernet.rotate() decrypts each token with
whichever old key succeeds, then re-encrypts it with the first (new) key.
Once all rows are rotated you can remove old keys from the config list.
"""
# pylint: disable=import-outside-toplevel
from superset import db
from superset.extension_storage.daos import _fernet
from superset.extension_storage.models import ExtensionStorage

fernet = _fernet()
entries = (
db.session.query(ExtensionStorage)
.filter(ExtensionStorage.is_encrypted.is_(True))
.all()
)
if not entries:
click.secho("No encrypted entries found.", fg="yellow")
return
failed = 0
for entry in entries:
try:
entry.value = fernet.rotate(entry.value)
except Exception as exc: # pylint: disable=broad-except
click.secho(
f"Failed to rotate key={entry.key} ext={entry.extension_id}: {exc}",
fg="red",
Comment on lines +131 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blind exception catch for generic Exception type

The code catches a blind Exception at line 133. Replace with specific exception types (e.g., ValueError, DecryptionError) to improve error handling clarity and avoid masking unexpected errors.

Code suggestion
Check the AI-generated fix before applying
Suggested change
try:
entry.value = fernet.rotate(entry.value)
except Exception as exc: # pylint: disable=broad-except
click.secho(
f"Failed to rotate key={entry.key} ext={entry.extension_id}: {exc}",
fg="red",
try:
entry.value = fernet.rotate(entry.value)
except (ValueError, TypeError) as exc:
click.secho(
f"Failed to rotate key={entry.key} ext={entry.extension_id}: {exc}",
fg="red",

Code Review Run #678cd6


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

err=True,
)
failed += 1
rotated = len(entries) - failed
Comment on lines +121 to +140

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Loading every encrypted row with .all() materializes the full table (including large binary payloads) in memory at once, which can exhaust memory on real datasets. Iterate in batches (and avoid retaining all ORM instances) so key rotation remains safe for large tables. [possible bug]

Severity Level: Major ⚠️
- ❌ Key rotation CLI may OOM on large extension_storage tables.
- ⚠️ Operators may be unable to retire old encryption keys.
- ⚠️ Encrypted extension data may remain unrotated indefinitely.
Suggested change
entries = (
db.session.query(ExtensionStorage)
.filter(ExtensionStorage.is_encrypted.is_(True))
.all()
)
if not entries:
click.secho("No encrypted entries found.", fg="yellow")
return
failed = 0
for entry in entries:
try:
entry.value = fernet.rotate(entry.value)
except Exception as exc: # pylint: disable=broad-except
click.secho(
f"Failed to rotate key={entry.key} ext={entry.extension_id}: {exc}",
fg="red",
err=True,
)
failed += 1
rotated = len(entries) - failed
encrypted_count = (
db.session.query(ExtensionStorage.id)
.filter(ExtensionStorage.is_encrypted.is_(True))
.count()
)
if encrypted_count == 0:
click.secho("No encrypted entries found.", fg="yellow")
return
failed = 0
rotated = 0
for entry in (
db.session.query(ExtensionStorage)
.filter(ExtensionStorage.is_encrypted.is_(True))
.yield_per(100)
):
try:
entry.value = fernet.rotate(entry.value)
db.session.flush()
db.session.expunge(entry)
rotated += 1
except Exception as exc: # pylint: disable=broad-except
click.secho(
f"Failed to rotate key={entry.key} ext={entry.extension_id}: {exc}",
fg="red",
err=True,
)
failed += 1
Steps of Reproduction ✅
1. Start a Superset instance with the extension storage model defined at
`superset/extension_storage/models.py:40-83` (the `ExtensionStorage` table with `value =
Column(LargeBinary(EXTENSION_STORAGE_MAX_SIZE))` and `EXTENSION_STORAGE_MAX_SIZE` set to
~16 MB at line 36).

2. Populate many encrypted rows in `extension_storage` by calling
`ExtensionStorageDAO.set(..., is_encrypted=True)` from
`superset/extension_storage/daos.py:142-188` in a loop, so that the combined size of
`value` blobs for encrypted rows is large relative to available RAM (for example,
thousands of rows with multi‑MB payloads).

3. Configure key rotation by setting `EXTENSION_STORAGE_ENCRYPTION_KEYS` in the Superset
config (used by `_fernet()` at `superset/extension_storage/daos.py:45-57`), then run the
CLI command `superset rotate-extension-storage-keys`, which invokes
`rotate_extension_storage_keys()` defined at `superset/cli/update.py:104-149`.

4. During execution, `rotate_extension_storage_keys()` issues
`db.session.query(ExtensionStorage).filter(ExtensionStorage.is_encrypted.is_(True)).all()`
at `superset/cli/update.py:121-124`, materializing every encrypted row (including all
large `value` blobs) into the `entries` list at once; on a sufficiently large table this
can cause excessive memory usage or `MemoryError`/process OOM, preventing the rotation
from completing.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/cli/update.py
**Line:** 121:140
**Comment:**
	*Possible Bug: Loading every encrypted row with `.all()` materializes the full table (including large binary payloads) in memory at once, which can exhaust memory on real datasets. Iterate in batches (and avoid retaining all ORM instances) so key rotation remains safe for large tables.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

click.secho(
f"Rotated {rotated}/{len(entries)} entries"
+ (
f" ({failed} failed — check EXTENSION_STORAGE_ENCRYPTION_KEYS)"
if failed
else ""
),
fg="green" if not failed else "yellow",
)


@click.command()
@with_appcontext
@click.option(
Expand Down
8 changes: 8 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ def _try_json_readsha(filepath: str, length: int) -> str | None:
# a sufficiently random sequence, ex: openssl rand -base64 42"
SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY") or CHANGE_ME_SECRET_KEY

# Keys used to encrypt extension storage entries (Tier 3 persistent storage).
# The first key in the list is used for new encryptions; all keys are tried
# on decryption. To rotate: prepend the new key, restart, then run
# ``superset rotate-extension-storage-keys``. Once all rows are re-encrypted
# the old key(s) can be removed from the list.
# Falls back to SECRET_KEY when not set.
EXTENSION_STORAGE_ENCRYPTION_KEYS: list[str] = []

# The SQLAlchemy connection string.
SQLALCHEMY_DATABASE_URI = (
f"""sqlite:///{os.path.join(DATA_DIR, "superset.db")}?check_same_thread=false"""
Expand Down
8 changes: 8 additions & 0 deletions superset/core/api/core_api_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def inject_dao_implementations() -> None:
from superset.daos.tag import TagDAO as HostTagDAO
from superset.daos.tasks import TaskDAO as HostTaskDAO
from superset.daos.user import UserDAO as HostUserDAO
from superset.extension_storage.daos import (
ExtensionStorageDAO as HostExtensionStorageDAO,
)

# Replace abstract classes in common.daos with concrete implementations
core_common_dao_module.DatasetDAO = HostDatasetDAO # type: ignore[assignment,misc]
Expand All @@ -67,6 +70,7 @@ def inject_dao_implementations() -> None:
core_common_dao_module.UserDAO = HostUserDAO # type: ignore[assignment,misc]
core_common_dao_module.TagDAO = HostTagDAO # type: ignore[assignment,misc]
core_common_dao_module.KeyValueDAO = HostKeyValueDAO # type: ignore[assignment,misc]
core_common_dao_module.ExtensionStorageDAO = HostExtensionStorageDAO # type: ignore[assignment,misc]

# Replace abstract classes in queries.daos
core_queries_dao_module.QueryDAO = HostQueryDAO # type: ignore[assignment,misc]
Expand All @@ -89,6 +93,9 @@ def inject_model_implementations() -> None:
from flask_appbuilder.security.sqla.models import User as HostUser

from superset.connectors.sqla.models import SqlaTable as HostDataset
from superset.extension_storage.models import (
ExtensionStorage as HostExtensionStorage,
)
from superset.key_value.models import KeyValueEntry as HostKeyValue
from superset.models.core import Database as HostDatabase
from superset.models.dashboard import Dashboard as HostDashboard
Expand All @@ -105,6 +112,7 @@ def inject_model_implementations() -> None:
core_common_models_module.User = HostUser # type: ignore[misc]
core_common_models_module.Tag = HostTag # type: ignore[misc]
core_common_models_module.KeyValue = HostKeyValue # type: ignore[misc]
core_common_models_module.ExtensionStorage = HostExtensionStorage # type: ignore[misc]

# In-place replacement in queries.models
core_queries_models_module.Query = HostQuery # type: ignore[misc]
Expand Down
Loading
Loading