You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/04/07 18:04:09 UTC
(superset) 01/05: feat(extensions): Tier 3 persistent storage — full CRUD + list endpoints, pagination, encryption
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch feat/tier3-extension-storage
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 5cb770d67fea17fe625fb78b32fd4b8b7c6f4be2
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Sun Apr 5 20:05:55 2026 -0700
feat(extensions): Tier 3 persistent storage — full CRUD + list endpoints, pagination, encryption
Implements the Extension Storage Layer (Tier 3) from the architecture proposal:
- New `extension_storage` table and SQLAlchemy model with three storage scopes:
global (shared), user-scoped (per-user), resource-linked (tied to a Superset resource)
- `ExtensionStorageDAO` with upsert semantics, Fernet encryption, and scope isolation
- REST API under `/api/v1/extensions/<publisher>/<name>/storage/persistent/`:
GET/PUT/DELETE for global/<key>, user/<key>, resources/<type>/<uuid>/<key>
GET list endpoints (with ?page & ?page_size pagination) for /global/, /user/, /resources/<type>/<uuid>/
- Alembic migration creating `extension_storage` table
- Unit tests covering all three scopes, upsert, delete, pagination, and encryption
(encrypted roundtrip, Fernet token verification, cross-scope coexistence)
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.pre-commit-config.yaml | 3 +-
UPDATING.md | 34 +
pyproject.toml | 2 +-
superset/core/api/core_api_injection.py | 8 +
superset/extension_storage/__init__.py | 16 +
superset/extension_storage/api.py | 697 +++++++++++++++++++++
superset/extension_storage/daos.py | 251 ++++++++
superset/extension_storage/models.py | 108 ++++
superset/initialization/__init__.py | 4 +
...2-00_e5f6a7b8c9d0_add_extension_assets_table.py | 84 +++
.../unit_tests/daos/test_extension_storage_dao.py | 318 ++++++++++
11 files changed, 1523 insertions(+), 2 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a15303b2a3c..1aaeeb9e69c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,6 +24,7 @@ repos:
hooks:
- id: mypy
name: mypy (main)
+ language_version: python3.10
args: [--check-untyped-defs]
exclude: ^superset-extensions-cli/
additional_dependencies: [
@@ -139,7 +140,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
diff --git a/UPDATING.md b/UPDATING.md
index 455e9a10ba4..6f15ae251eb 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,40 @@ assists people when migrating to a new version.
## Next
+### 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)
+```
+
+The DAO is injectable via `superset_core`:
+
+```python
+from superset_core.common.daos import ExtensionStorageDAO
+```
+
+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`.
+
### 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.
diff --git a/pyproject.toml b/pyproject.toml
index 12d5224cb38..301abce2035 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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
@@ -356,7 +357,6 @@ select = [
ignore = [
"S101",
- "PT004", # Fixtures that don't return values - underscore prefix conflicts with pytest usage
"PT006",
"T201",
"N999",
diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py
index 34aa998bd54..a370d799c35 100644
--- a/superset/core/api/core_api_injection.py
+++ b/superset/core/api/core_api_injection.py
@@ -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]
@@ -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]
@@ -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
@@ -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]
diff --git a/superset/extension_storage/__init__.py b/superset/extension_storage/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/extension_storage/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/extension_storage/api.py b/superset/extension_storage/api.py
new file mode 100644
index 00000000000..0280af020e7
--- /dev/null
+++ b/superset/extension_storage/api.py
@@ -0,0 +1,697 @@
+# 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.
+
+"""REST API for extension Tier-3 persistent storage.
+
+URL structure (all under /api/v1/extensions/<publisher>/<name>/storage/persistent/):
+
+ GET /global/
+ Paginated list of global keys (?page=0&page_size=25&category=…).
+
+ GET/PUT/DELETE /global/<key>
+ Global key-value: shared across all users of this extension.
+
+ GET /user/
+ Paginated list of user-scoped keys (?page=0&page_size=25&category=…).
+
+ GET/PUT/DELETE /user/<key>
+ User-scoped key-value: isolated per authenticated user.
+
+ GET /resources/<resource_type>/<resource_uuid>/
+ Paginated list of resource-linked keys (?page=0&page_size=25&category=…).
+
+ GET/PUT/DELETE /resources/<resource_type>/<resource_uuid>/<key>
+ Resource-linked key-value: tied to a specific Superset resource.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from flask import request, Response
+from flask_appbuilder.api import expose, protect, safe
+
+from superset import db
+from superset.extension_storage.daos import ExtensionStorageDAO
+from superset.superset_typing import FlaskResponse
+from superset.utils.core import get_user_id
+from superset.views.base_api import BaseSupersetApi
+
+logger = logging.getLogger(__name__)
+
+_MIME_DEFAULT = "application/json"
+_PAGE_SIZE_DEFAULT = 25
+_PAGE_SIZE_MAX = 100
+
+
+def _parse_pagination() -> tuple[int, int]:
+ """Return (offset, page_size) from ?page=&page_size= query params."""
+ try:
+ page = max(0, int(request.args.get("page", 0)))
+ except (TypeError, ValueError):
+ page = 0
+ try:
+ page_size = min(
+ _PAGE_SIZE_MAX,
+ max(1, int(request.args.get("page_size", _PAGE_SIZE_DEFAULT))),
+ )
+ except (TypeError, ValueError):
+ page_size = _PAGE_SIZE_DEFAULT
+ return page * page_size, page_size
+
+
+def _extension_id(publisher: str, name: str) -> str:
+ return f"{publisher}.{name}"
+
+
+def _entry_to_dict(entry: object) -> dict[str, object]:
+ from superset.extension_storage.models import ExtensionStorage
+
+ assert isinstance(entry, ExtensionStorage)
+ return {
+ "key": entry.key,
+ "value_type": entry.value_type,
+ "category": entry.category,
+ "description": entry.description,
+ "is_encrypted": entry.is_encrypted,
+ "uuid": str(entry.uuid),
+ }
+
+
+class ExtensionStorageRestApi(BaseSupersetApi):
+ """Generic persistent storage for extensions (Tier 3)."""
+
+ route_base = "/api/v1/extensions"
+ resource_name = "extensions"
+ openapi_spec_tag = "Extension Storage"
+
+ # ── Global scope ──────────────────────────────────────────────────────────
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/global/",
+ methods=("GET",),
+ )
+ @protect()
+ @safe
+ def list_global(self, publisher: str, name: str) -> FlaskResponse:
+ """List global (non-user-scoped) storage entries with pagination.
+ ---
+ get:
+ summary: List global persistent storage entries
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: category
+ in: query
+ required: false
+ schema: {type: string}
+ - name: page
+ in: query
+ required: false
+ schema: {type: integer, default: 0}
+ - name: page_size
+ in: query
+ required: false
+ schema: {type: integer, default: 25}
+ responses:
+ 200:
+ description: Success
+ """
+ ext_id = _extension_id(publisher, name)
+ category = request.args.get("category")
+ offset, page_size = _parse_pagination()
+ entries = ExtensionStorageDAO.list_global(ext_id, category=category)
+ total = len(entries)
+ page_entries = entries[offset : offset + page_size]
+ return self.response(
+ 200,
+ result=[_entry_to_dict(e) for e in page_entries],
+ count=total,
+ )
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/global/<key>",
+ methods=("GET",),
+ )
+ @protect()
+ @safe
+ def get_global(self, publisher: str, name: str, key: str) -> FlaskResponse:
+ """Get a global (non-user-scoped) persistent value.
+ ---
+ get:
+ summary: Get global persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Success
+ 404:
+ description: Not found
+ """
+ ext_id = _extension_id(publisher, name)
+ value = ExtensionStorageDAO.get_value(ext_id, key)
+ if value is None:
+ return self.response(404, message="Not found")
+ entry = ExtensionStorageDAO.get(ext_id, key)
+ assert entry is not None
+ mime = entry.value_type
+ return Response(value, status=200, mimetype=mime)
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/global/<key>",
+ methods=("PUT",),
+ )
+ @protect()
+ @safe
+ def set_global(self, publisher: str, name: str, key: str) -> FlaskResponse:
+ """Create or update a global persistent value.
+ ---
+ put:
+ summary: Set global persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ value: {type: string}
+ value_type: {type: string}
+ category: {type: string}
+ description: {type: string}
+ is_encrypted: {type: boolean}
+ responses:
+ 200:
+ description: Success
+ """
+ ext_id = _extension_id(publisher, name)
+ body = request.get_json(force=True) or {}
+ raw_value: str | bytes = body.get("value", "")
+ value_bytes = raw_value.encode() if isinstance(raw_value, str) else raw_value
+ entry = ExtensionStorageDAO.set(
+ extension_id=ext_id,
+ key=key,
+ value=value_bytes,
+ value_type=body.get("value_type", _MIME_DEFAULT),
+ category=body.get("category"),
+ description=body.get("description"),
+ is_encrypted=bool(body.get("is_encrypted", False)),
+ )
+ db.session.commit()
+ return self.response(200, result=_entry_to_dict(entry))
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/global/<key>",
+ methods=("DELETE",),
+ )
+ @protect()
+ @safe
+ def delete_global(self, publisher: str, name: str, key: str) -> FlaskResponse:
+ """Delete a global persistent value.
+ ---
+ delete:
+ summary: Delete global persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Deleted
+ 404:
+ description: Not found
+ """
+ ext_id = _extension_id(publisher, name)
+ deleted = ExtensionStorageDAO.delete(ext_id, key)
+ if not deleted:
+ return self.response(404, message="Not found")
+ db.session.commit()
+ return self.response(200, message="Deleted")
+
+ # ── User scope ────────────────────────────────────────────────────────────
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/user/",
+ methods=("GET",),
+ )
+ @protect()
+ @safe
+ def list_user(self, publisher: str, name: str) -> FlaskResponse:
+ """List user-scoped storage entries for the current user with pagination.
+ ---
+ get:
+ summary: List user-scoped storage entries
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: category
+ in: query
+ required: false
+ schema: {type: string}
+ - name: page
+ in: query
+ required: false
+ schema: {type: integer, default: 0}
+ - name: page_size
+ in: query
+ required: false
+ schema: {type: integer, default: 25}
+ responses:
+ 200:
+ description: Success
+ """
+ user_id = get_user_id()
+ if not user_id:
+ return self.response(401, message="Authentication required")
+ ext_id = _extension_id(publisher, name)
+ category = request.args.get("category")
+ offset, page_size = _parse_pagination()
+ entries = ExtensionStorageDAO.list_user(ext_id, user_id, category=category)
+ total = len(entries)
+ page_entries = entries[offset : offset + page_size]
+ return self.response(
+ 200, result=[_entry_to_dict(e) for e in page_entries], count=total
+ )
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/user/<key>",
+ methods=("GET",),
+ )
+ @protect()
+ @safe
+ def get_user(self, publisher: str, name: str, key: str) -> FlaskResponse:
+ """Get a user-scoped persistent value.
+ ---
+ get:
+ summary: Get user-scoped persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Success
+ 404:
+ description: Not found
+ """
+ user_id = get_user_id()
+ if not user_id:
+ return self.response(401, message="Authentication required")
+ ext_id = _extension_id(publisher, name)
+ value = ExtensionStorageDAO.get_value(ext_id, key, user_fk=user_id)
+ if value is None:
+ return self.response(404, message="Not found")
+ entry = ExtensionStorageDAO.get(ext_id, key, user_fk=user_id)
+ assert entry is not None
+ return Response(value, status=200, mimetype=entry.value_type)
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/user/<key>",
+ methods=("PUT",),
+ )
+ @protect()
+ @safe
+ def set_user(self, publisher: str, name: str, key: str) -> FlaskResponse:
+ """Create or update a user-scoped persistent value.
+ ---
+ put:
+ summary: Set user-scoped persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Success
+ """
+ user_id = get_user_id()
+ if not user_id:
+ return self.response(401, message="Authentication required")
+ ext_id = _extension_id(publisher, name)
+ body = request.get_json(force=True) or {}
+ raw_value: str | bytes = body.get("value", "")
+ value_bytes = raw_value.encode() if isinstance(raw_value, str) else raw_value
+ entry = ExtensionStorageDAO.set(
+ extension_id=ext_id,
+ key=key,
+ value=value_bytes,
+ value_type=body.get("value_type", _MIME_DEFAULT),
+ user_fk=user_id,
+ category=body.get("category"),
+ description=body.get("description"),
+ is_encrypted=bool(body.get("is_encrypted", False)),
+ )
+ db.session.commit()
+ return self.response(200, result=_entry_to_dict(entry))
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/user/<key>",
+ methods=("DELETE",),
+ )
+ @protect()
+ @safe
+ def delete_user(self, publisher: str, name: str, key: str) -> FlaskResponse:
+ """Delete a user-scoped persistent value.
+ ---
+ delete:
+ summary: Delete user-scoped persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Deleted
+ 404:
+ description: Not found
+ """
+ user_id = get_user_id()
+ if not user_id:
+ return self.response(401, message="Authentication required")
+ ext_id = _extension_id(publisher, name)
+ deleted = ExtensionStorageDAO.delete(ext_id, key, user_fk=user_id)
+ if not deleted:
+ return self.response(404, message="Not found")
+ db.session.commit()
+ return self.response(200, message="Deleted")
+
+ # ── Resource scope ────────────────────────────────────────────────────────
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/resources/<resource_type>/<resource_uuid>/",
+ methods=("GET",),
+ )
+ @protect()
+ @safe
+ def list_resource(
+ self,
+ publisher: str,
+ name: str,
+ resource_type: str,
+ resource_uuid: str,
+ ) -> FlaskResponse:
+ """List storage entries linked to a resource with pagination.
+ ---
+ get:
+ summary: List resource-linked storage entries
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_type
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_uuid
+ in: path
+ required: true
+ schema: {type: string}
+ - name: category
+ in: query
+ required: false
+ schema: {type: string}
+ - name: page
+ in: query
+ required: false
+ schema: {type: integer, default: 0}
+ - name: page_size
+ in: query
+ required: false
+ schema: {type: integer, default: 25}
+ responses:
+ 200:
+ description: Success
+ """
+ ext_id = _extension_id(publisher, name)
+ category = request.args.get("category")
+ offset, page_size = _parse_pagination()
+ entries = ExtensionStorageDAO.list_resource(
+ ext_id, resource_type, resource_uuid, category=category
+ )
+ total = len(entries)
+ page_entries = entries[offset : offset + page_size]
+ return self.response(
+ 200, result=[_entry_to_dict(e) for e in page_entries], count=total
+ )
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/resources/<resource_type>/<resource_uuid>/<key>",
+ methods=("GET",),
+ )
+ @protect()
+ @safe
+ def get_resource(
+ self,
+ publisher: str,
+ name: str,
+ resource_type: str,
+ resource_uuid: str,
+ key: str,
+ ) -> FlaskResponse:
+ """Get a resource-linked persistent value.
+ ---
+ get:
+ summary: Get resource-linked persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_type
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_uuid
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Success
+ 404:
+ description: Not found
+ """
+ ext_id = _extension_id(publisher, name)
+ value = ExtensionStorageDAO.get_value(
+ ext_id, key, resource_type=resource_type, resource_uuid=resource_uuid
+ )
+ if value is None:
+ return self.response(404, message="Not found")
+ entry = ExtensionStorageDAO.get(
+ ext_id, key, resource_type=resource_type, resource_uuid=resource_uuid
+ )
+ assert entry is not None
+ return Response(value, status=200, mimetype=entry.value_type)
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/resources/<resource_type>/<resource_uuid>/<key>",
+ methods=("PUT",),
+ )
+ @protect()
+ @safe
+ def set_resource(
+ self,
+ publisher: str,
+ name: str,
+ resource_type: str,
+ resource_uuid: str,
+ key: str,
+ ) -> FlaskResponse:
+ """Create or update a resource-linked persistent value.
+ ---
+ put:
+ summary: Set resource-linked persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_type
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_uuid
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Success
+ """
+ ext_id = _extension_id(publisher, name)
+ body = request.get_json(force=True) or {}
+ raw_value: str | bytes = body.get("value", "")
+ value_bytes = raw_value.encode() if isinstance(raw_value, str) else raw_value
+ entry = ExtensionStorageDAO.set(
+ extension_id=ext_id,
+ key=key,
+ value=value_bytes,
+ value_type=body.get("value_type", _MIME_DEFAULT),
+ resource_type=resource_type,
+ resource_uuid=resource_uuid,
+ category=body.get("category"),
+ description=body.get("description"),
+ is_encrypted=bool(body.get("is_encrypted", False)),
+ )
+ db.session.commit()
+ return self.response(200, result=_entry_to_dict(entry))
+
+ @expose(
+ "/<publisher>/<name>/storage/persistent/resources/<resource_type>/<resource_uuid>/<key>",
+ methods=("DELETE",),
+ )
+ @protect()
+ @safe
+ def delete_resource(
+ self,
+ publisher: str,
+ name: str,
+ resource_type: str,
+ resource_uuid: str,
+ key: str,
+ ) -> FlaskResponse:
+ """Delete a resource-linked persistent value.
+ ---
+ delete:
+ summary: Delete resource-linked persistent value
+ parameters:
+ - name: publisher
+ in: path
+ required: true
+ schema: {type: string}
+ - name: name
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_type
+ in: path
+ required: true
+ schema: {type: string}
+ - name: resource_uuid
+ in: path
+ required: true
+ schema: {type: string}
+ - name: key
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ 200:
+ description: Deleted
+ 404:
+ description: Not found
+ """
+ ext_id = _extension_id(publisher, name)
+ deleted = ExtensionStorageDAO.delete(
+ ext_id, key, resource_type=resource_type, resource_uuid=resource_uuid
+ )
+ if not deleted:
+ return self.response(404, message="Not found")
+ db.session.commit()
+ return self.response(200, message="Deleted")
diff --git a/superset/extension_storage/daos.py b/superset/extension_storage/daos.py
new file mode 100644
index 00000000000..eef2d94d596
--- /dev/null
+++ b/superset/extension_storage/daos.py
@@ -0,0 +1,251 @@
+# 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
+from flask import current_app
+from sqlalchemy import and_
+
+from superset import db
+from superset.extension_storage.models import ExtensionStorage
+
+logger = logging.getLogger(__name__)
+
+
+def _fernet() -> Fernet:
+ """Return a Fernet instance derived from the app SECRET_KEY."""
+ raw_key = current_app.config["SECRET_KEY"]
+ if isinstance(raw_key, str):
+ raw_key = raw_key.encode()
+ derived = hashlib.sha256(raw_key).digest()
+ return Fernet(base64.urlsafe_b64encode(derived))
+
+
+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 a storage entry, decrypting the value if needed."""
+ 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:
+ return _fernet().decrypt(entry.value)
+ 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()
diff --git a/superset/extension_storage/models.py b/superset/extension_storage/models.py
new file mode 100644
index 00000000000..d4eff7a7203
--- /dev/null
+++ b/superset/extension_storage/models.py
@@ -0,0 +1,108 @@
+# 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.
+
+import uuid as uuid_module
+
+from flask_appbuilder import Model
+from sqlalchemy import (
+ Boolean,
+ Column,
+ ForeignKey,
+ Index,
+ Integer,
+ LargeBinary,
+ String,
+ Text,
+)
+from sqlalchemy.orm import backref, relationship
+from sqlalchemy_utils import UUIDType
+
+from superset.models.helpers import AuditMixinNullable
+
+# 16 MB — matches the KeyValue store limit.
+EXTENSION_STORAGE_MAX_SIZE = 2**24 - 1
+
+
+class ExtensionStorage(AuditMixinNullable, Model):
+ """Generic persistent key-value storage for extensions (Tier 3).
+
+ Each row is identified by (extension_id, user_fk, resource_type,
+ resource_uuid, key):
+
+ * Global scope — user_fk IS NULL, resource_type IS NULL
+ * User scope — user_fk set, resource_type IS NULL
+ * Resource scope — resource_type + resource_uuid set (user_fk optional)
+
+ The payload is stored as raw bytes (value) with a MIME-type hint
+ (value_type). When is_encrypted is True the value has been encrypted
+ at the DAO layer using Fernet and must be decrypted before use.
+ """
+
+ __tablename__ = "extension_storage"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ uuid = Column(
+ UUIDType(binary=True),
+ default=uuid_module.uuid4,
+ unique=True,
+ nullable=False,
+ )
+
+ # Extension identity
+ extension_id = Column(String(255), nullable=False)
+
+ # Scope discriminators — all nullable; NULLs define the scope (see docstring)
+ user_fk = Column(Integer, ForeignKey("ab_user.id"), nullable=True)
+ resource_type = Column(String(64), nullable=True)
+ resource_uuid = Column(String(36), nullable=True)
+
+ # Storage key within the scope
+ key = Column(String(255), nullable=False)
+
+ # Optional metadata
+ category = Column(String(64), nullable=True)
+ description = Column(Text, nullable=True)
+
+ # Payload
+ value = Column(LargeBinary(EXTENSION_STORAGE_MAX_SIZE), nullable=False)
+ value_type = Column(String(255), nullable=False, default="application/json")
+ is_encrypted = Column(Boolean, nullable=False, default=False)
+
+ user = relationship(
+ "User",
+ backref=backref("extension_storage_entries", cascade="all, delete-orphan"),
+ foreign_keys=[user_fk],
+ )
+
+ __table_args__ = (
+ # Composite index covering all lookup dimensions
+ Index(
+ "ix_ext_storage_lookup",
+ "extension_id",
+ "user_fk",
+ "resource_type",
+ "resource_uuid",
+ "key",
+ ),
+ Index("ix_ext_storage_extension_id", "extension_id"),
+ )
+
+ def __repr__(self) -> str:
+ return (
+ f"<ExtensionStorage {self.extension_id}/"
+ f"user={self.user_fk}/res={self.resource_type}/{self.key}>"
+ )
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 878ed2f52d5..c0a1ae78054 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -273,6 +273,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(SqlLabPermalinkRestApi)
appbuilder.add_api(LogRestApi)
+ from superset.extension_storage.api import ExtensionStorageRestApi
+
+ appbuilder.add_api(ExtensionStorageRestApi)
+
if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"):
from superset.extensions.api import ExtensionsRestApi
diff --git a/superset/migrations/versions/2026-03-20_12-00_e5f6a7b8c9d0_add_extension_assets_table.py b/superset/migrations/versions/2026-03-20_12-00_e5f6a7b8c9d0_add_extension_assets_table.py
new file mode 100644
index 00000000000..cf7bcbcfe52
--- /dev/null
+++ b/superset/migrations/versions/2026-03-20_12-00_e5f6a7b8c9d0_add_extension_assets_table.py
@@ -0,0 +1,84 @@
+# 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.
+"""add_extension_storage_table
+
+Revision ID: e5f6a7b8c9d0
+Revises: a1b2c3d4e5f6
+Create Date: 2026-03-20 12:00:00.000000
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "e5f6a7b8c9d0"
+down_revision = "a1b2c3d4e5f6"
+
+import sqlalchemy as sa # noqa: E402
+from alembic import op # noqa: E402
+from sqlalchemy_utils import UUIDType # noqa: E402
+
+
+def upgrade() -> None:
+ op.create_table(
+ "extension_storage",
+ sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
+ sa.Column("uuid", UUIDType(binary=True), nullable=False),
+ sa.Column("extension_id", sa.String(255), nullable=False),
+ sa.Column("user_fk", sa.Integer(), nullable=True),
+ sa.Column("resource_type", sa.String(64), nullable=True),
+ sa.Column("resource_uuid", sa.String(36), nullable=True),
+ sa.Column("key", sa.String(255), nullable=False),
+ sa.Column("category", sa.String(64), nullable=True),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("value", sa.LargeBinary(2**24 - 1), nullable=False),
+ sa.Column(
+ "value_type",
+ sa.String(255),
+ nullable=False,
+ server_default="application/json",
+ ),
+ sa.Column(
+ "is_encrypted",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ sa.Column("created_on", sa.DateTime(), nullable=True),
+ sa.Column("changed_on", sa.DateTime(), nullable=True),
+ sa.Column("created_by_fk", sa.Integer(), nullable=True),
+ sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(["user_fk"], ["ab_user.id"]),
+ sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
+ sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("uuid"),
+ )
+ op.create_index(
+ "ix_ext_storage_extension_id",
+ "extension_storage",
+ ["extension_id"],
+ )
+ op.create_index(
+ "ix_ext_storage_lookup",
+ "extension_storage",
+ ["extension_id", "user_fk", "resource_type", "resource_uuid", "key"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_ext_storage_lookup", "extension_storage")
+ op.drop_index("ix_ext_storage_extension_id", "extension_storage")
+ op.drop_table("extension_storage")
diff --git a/tests/unit_tests/daos/test_extension_storage_dao.py b/tests/unit_tests/daos/test_extension_storage_dao.py
new file mode 100644
index 00000000000..b2f62c9de51
--- /dev/null
+++ b/tests/unit_tests/daos/test_extension_storage_dao.py
@@ -0,0 +1,318 @@
+# 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.
+
+"""Unit tests for ExtensionStorageDAO.
+
+Covers the three storage scopes (global, user, resource) and verifies that
+scope isolation, upsert semantics, and delete behaviour all work correctly.
+Encryption is tested via a mock Fernet key derived from the test SECRET_KEY.
+"""
+
+from typing import Iterator
+
+import pytest
+from sqlalchemy.orm.session import Session
+
+from superset.extension_storage.daos import ExtensionStorageDAO
+from superset.extension_storage.models import ExtensionStorage
+
+
+@pytest.fixture
+def session(session: Session) -> Iterator[Session]:
+ """Extend the base session fixture with the extension_storage table."""
+ engine = session.get_bind()
+ ExtensionStorage.metadata.create_all(engine)
+ yield session
+ session.rollback()
+
+
+EXT_A = "community.jupyter-notebook"
+EXT_B = "community.other-extension"
+USER_1 = 1
+USER_2 = 2
+RES_TYPE = "dashboard"
+RES_UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+
+def _make_entry(
+ session: Session,
+ *,
+ extension_id: str = EXT_A,
+ key: str = "mykey",
+ value: bytes = b"hello",
+ value_type: str = "application/json",
+ user_fk: int | None = None,
+ resource_type: str | None = None,
+ resource_uuid: str | None = None,
+ category: str | None = None,
+) -> ExtensionStorage:
+ entry = ExtensionStorage(
+ extension_id=extension_id,
+ key=key,
+ value=value,
+ value_type=value_type,
+ user_fk=user_fk,
+ resource_type=resource_type,
+ resource_uuid=resource_uuid,
+ category=category,
+ is_encrypted=False,
+ )
+ session.add(entry)
+ session.flush()
+ return entry
+
+
+# ── Global scope ──────────────────────────────────────────────────────────────
+
+
+def test_set_and_get_global(session: Session) -> None:
+ ExtensionStorageDAO.set(EXT_A, "cfg", b'{"theme":"dark"}')
+ val = ExtensionStorageDAO.get_value(EXT_A, "cfg")
+ assert val == b'{"theme":"dark"}'
+
+
+def test_global_upsert_updates_value(session: Session) -> None:
+ ExtensionStorageDAO.set(EXT_A, "cfg", b"v1")
+ ExtensionStorageDAO.set(EXT_A, "cfg", b"v2")
+ entries = ExtensionStorageDAO.list_global(EXT_A)
+ assert len(entries) == 1
+ assert entries[0].value == b"v2"
+
+
+def test_global_delete(session: Session) -> None:
+ ExtensionStorageDAO.set(EXT_A, "cfg", b"data")
+ deleted = ExtensionStorageDAO.delete(EXT_A, "cfg")
+ assert deleted is True
+ assert ExtensionStorageDAO.get_value(EXT_A, "cfg") is None
+
+
+def test_global_delete_missing_returns_false(session: Session) -> None:
+ assert ExtensionStorageDAO.delete(EXT_A, "nonexistent") is False
+
+
+def test_global_scope_isolated_from_user_scope(session: Session) -> None:
+ """A global key and a same-named user key do not collide."""
+ ExtensionStorageDAO.set(EXT_A, "key", b"global-val")
+ ExtensionStorageDAO.set(EXT_A, "key", b"user-val", user_fk=USER_1)
+ assert ExtensionStorageDAO.get_value(EXT_A, "key") == b"global-val"
+ assert ExtensionStorageDAO.get_value(EXT_A, "key", user_fk=USER_1) == b"user-val"
+
+
+def test_list_global_filtered_by_extension(session: Session) -> None:
+ _make_entry(session, extension_id=EXT_A, key="k1")
+ _make_entry(session, extension_id=EXT_A, key="k2")
+ _make_entry(session, extension_id=EXT_B, key="k3")
+ results = ExtensionStorageDAO.list_global(EXT_A)
+ keys = {r.key for r in results}
+ assert keys == {"k1", "k2"}
+
+
+def test_list_global_filtered_by_category(session: Session) -> None:
+ _make_entry(session, key="k1", category="notebook")
+ _make_entry(session, key="k2", category="chart")
+ results = ExtensionStorageDAO.list_global(EXT_A, category="notebook")
+ assert all(r.category == "notebook" for r in results)
+ assert {r.key for r in results} == {"k1"}
+
+
+# ── User scope ────────────────────────────────────────────────────────────────
+
+
+def test_set_and_get_user(session: Session) -> None:
+ ExtensionStorageDAO.set(EXT_A, "prefs", b"data", user_fk=USER_1)
+ val = ExtensionStorageDAO.get_value(EXT_A, "prefs", user_fk=USER_1)
+ assert val == b"data"
+
+
+def test_user_scope_isolated_between_users(session: Session) -> None:
+ ExtensionStorageDAO.set(EXT_A, "prefs", b"u1", user_fk=USER_1)
+ ExtensionStorageDAO.set(EXT_A, "prefs", b"u2", user_fk=USER_2)
+ assert ExtensionStorageDAO.get_value(EXT_A, "prefs", user_fk=USER_1) == b"u1"
+ assert ExtensionStorageDAO.get_value(EXT_A, "prefs", user_fk=USER_2) == b"u2"
+
+
+def test_user_upsert(session: Session) -> None:
+ ExtensionStorageDAO.set(EXT_A, "prefs", b"old", user_fk=USER_1)
+ ExtensionStorageDAO.set(EXT_A, "prefs", b"new", user_fk=USER_1)
+ entries = ExtensionStorageDAO.list_user(EXT_A, USER_1)
+ assert len(entries) == 1
+ assert entries[0].value == b"new"
+
+
+def test_list_user_does_not_include_other_users(session: Session) -> None:
+ _make_entry(session, key="u1k", user_fk=USER_1)
+ _make_entry(session, key="u2k", user_fk=USER_2)
+ results = ExtensionStorageDAO.list_user(EXT_A, USER_1)
+ assert all(r.user_fk == USER_1 for r in results)
+
+
+# ── Resource scope ────────────────────────────────────────────────────────────
+
+
+def test_set_and_get_resource(session: Session) -> None:
+ ExtensionStorageDAO.set(
+ EXT_A,
+ "state",
+ b"res-val",
+ resource_type=RES_TYPE,
+ resource_uuid=RES_UUID,
+ )
+ val = ExtensionStorageDAO.get_value(
+ EXT_A, "state", resource_type=RES_TYPE, resource_uuid=RES_UUID
+ )
+ assert val == b"res-val"
+
+
+def test_resource_scope_isolated_by_uuid(session: Session) -> None:
+ uuid2 = "11111111-2222-3333-4444-555555555555"
+ ExtensionStorageDAO.set(
+ EXT_A, "k", b"r1", resource_type=RES_TYPE, resource_uuid=RES_UUID
+ )
+ ExtensionStorageDAO.set(
+ EXT_A, "k", b"r2", resource_type=RES_TYPE, resource_uuid=uuid2
+ )
+ assert (
+ ExtensionStorageDAO.get_value(
+ EXT_A, "k", resource_type=RES_TYPE, resource_uuid=RES_UUID
+ )
+ == b"r1"
+ )
+ assert (
+ ExtensionStorageDAO.get_value(
+ EXT_A, "k", resource_type=RES_TYPE, resource_uuid=uuid2
+ )
+ == b"r2"
+ )
+
+
+def test_list_resource(session: Session) -> None:
+ _make_entry(session, key="ra", resource_type=RES_TYPE, resource_uuid=RES_UUID)
+ _make_entry(session, key="rb", resource_type=RES_TYPE, resource_uuid=RES_UUID)
+ _make_entry(session, key="rc", resource_type="chart", resource_uuid=RES_UUID)
+ results = ExtensionStorageDAO.list_resource(EXT_A, RES_TYPE, RES_UUID)
+ keys = {r.key for r in results}
+ assert keys == {"ra", "rb"}
+
+
+# ── Pagination ────────────────────────────────────────────────────────────────
+
+
+def test_list_user_pagination(session: Session) -> None:
+ """list_user returns all rows; callers slice for pagination."""
+ for i in range(10):
+ _make_entry(session, key=f"page-{i}", user_fk=USER_1)
+ all_entries = ExtensionStorageDAO.list_user(EXT_A, USER_1)
+ assert len(all_entries) == 10
+ # Simulate first page (page_size=3)
+ page0 = all_entries[0:3]
+ page1 = all_entries[3:6]
+ assert len(page0) == 3
+ assert len(page1) == 3
+ # No overlap between pages
+ assert {e.key for e in page0}.isdisjoint({e.key for e in page1})
+
+
+def test_list_global_pagination(session: Session) -> None:
+ for i in range(5):
+ _make_entry(session, key=f"g-{i}")
+ all_entries = ExtensionStorageDAO.list_global(EXT_A)
+ assert len(all_entries) == 5
+ page = all_entries[0:2]
+ assert len(page) == 2
+
+
+def test_list_resource_pagination(session: Session) -> None:
+ for i in range(4):
+ _make_entry(
+ session, key=f"r-{i}", resource_type=RES_TYPE, resource_uuid=RES_UUID
+ )
+ all_entries = ExtensionStorageDAO.list_resource(EXT_A, RES_TYPE, RES_UUID)
+ assert len(all_entries) == 4
+
+
+# ── Encryption ────────────────────────────────────────────────────────────────
+
+
+def test_encrypted_roundtrip(session: Session) -> None:
+ """Values stored with is_encrypted=True survive a set → get_value roundtrip."""
+ secret = b"supersecret"
+ ExtensionStorageDAO.set(EXT_A, "secret_key", secret, is_encrypted=True)
+ entry = ExtensionStorageDAO.get(EXT_A, "secret_key")
+ assert entry is not None
+ assert entry.is_encrypted is True
+ # Raw stored bytes must differ from the plaintext
+ assert entry.value != secret
+ # get_value must decrypt transparently
+ assert ExtensionStorageDAO.get_value(EXT_A, "secret_key") == secret
+
+
+def test_encrypted_upsert_preserves_encryption(session: Session) -> None:
+ """Upserting an encrypted value keeps is_encrypted and re-encrypts the new value."""
+ ExtensionStorageDAO.set(EXT_A, "enc", b"v1", is_encrypted=True)
+ ExtensionStorageDAO.set(EXT_A, "enc", b"v2", is_encrypted=True)
+ assert ExtensionStorageDAO.get_value(EXT_A, "enc") == b"v2"
+
+
+def test_unencrypted_and_encrypted_keys_coexist(session: Session) -> None:
+ """Encrypted and plaintext entries in the same scope do not interfere."""
+ ExtensionStorageDAO.set(EXT_A, "plain", b"hello")
+ ExtensionStorageDAO.set(EXT_A, "secret", b"world", is_encrypted=True)
+ assert ExtensionStorageDAO.get_value(EXT_A, "plain") == b"hello"
+ assert ExtensionStorageDAO.get_value(EXT_A, "secret") == b"world"
+ plain_entry = ExtensionStorageDAO.get(EXT_A, "plain")
+ assert plain_entry is not None
+ assert plain_entry.is_encrypted is False
+ assert plain_entry.value == b"hello"
+
+
+def test_encrypted_user_scoped(session: Session) -> None:
+ """Encryption works for user-scoped entries."""
+ payload = b'{"token":"abc123"}'
+ ExtensionStorageDAO.set(EXT_A, "auth", payload, user_fk=USER_1, is_encrypted=True)
+ assert ExtensionStorageDAO.get_value(EXT_A, "auth", user_fk=USER_1) == payload
+ raw = ExtensionStorageDAO.get(EXT_A, "auth", user_fk=USER_1)
+ assert raw is not None
+ assert raw.value != payload # stored ciphertext differs from plaintext
+
+
+def test_encrypted_value_is_fernet_token(session: Session) -> None:
+ """The stored bytes are a valid Fernet token (starts with 'gAAA' in base64)."""
+ import base64
+
+ ExtensionStorageDAO.set(EXT_A, "fernet_check", b"data", is_encrypted=True)
+ entry = ExtensionStorageDAO.get(EXT_A, "fernet_check")
+ assert entry is not None
+ # Fernet tokens are URL-safe base64 and always start with version byte 0x80
+ decoded = base64.urlsafe_b64decode(entry.value + b"==")
+ assert decoded[0] == 0x80, "stored value should be a Fernet token"
+
+
+# ── Value blob ────────────────────────────────────────────────────────────────
+
+
+def test_binary_value_roundtrip(session: Session) -> None:
+ payload = bytes(range(256))
+ ExtensionStorageDAO.set(
+ EXT_A, "binkey", payload, value_type="application/octet-stream"
+ )
+ assert ExtensionStorageDAO.get_value(EXT_A, "binkey") == payload
+ entry = ExtensionStorageDAO.get(EXT_A, "binkey")
+ assert entry is not None
+ assert entry.value_type == "application/octet-stream"