Repository navigation
feat(extensions): Tier 3 persistent key-value storage for extensions #39168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e9f795a
bc0003e
596a35d
6d6d88c
02dbaeb
02d47bb
e59ce73
ea04aa3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| "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 | | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blind exception catch for generic Exception type
The code catches a blind Code suggestionCheck the AI-generated fix before applying
Suggested change
Code Review Run #678cd6 Should Bito avoid suggestions like this for future reviews? (Manage Rules)
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| err=True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failed += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rotated = len(entries) - failed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+121
to
+140
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Loading every encrypted row with Severity Level: Major
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs claim
valuecan 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.