You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/04/10 18:55:30 UTC

(superset) branch ext-storage-tiers-1-2 updated: feat(extensions): add Tier 3 persistent state storage (#39227)

This is an automated email from the ASF dual-hosted git repository.

michaelsmolina pushed a commit to branch ext-storage-tiers-1-2
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/ext-storage-tiers-1-2 by this push:
     new 62203d488b0 feat(extensions): add Tier 3 persistent state storage (#39227)
62203d488b0 is described below

commit 62203d488b0b96c14f9754d47113bfa3410c3676
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Fri Apr 10 14:55:23 2026 -0400

    feat(extensions): add Tier 3 persistent state storage (#39227)
    
    Co-authored-by: Amin Ghadersohi <am...@gmail.com>
    Co-authored-by: Claude Sonnet 4.6 <no...@anthropic.com>
---
 docs/developer_docs/extensions/storage.md          |  92 ++++++-
 .../superset_core/extensions/storage/__init__.py   |  13 +-
 .../extensions/storage/persistent_state.py         | 129 ++++++++++
 .../packages/superset-core/src/extensions/index.ts |   7 +
 .../packages/superset-core/src/storage/index.ts    |   1 +
 .../superset-core/src/storage/persistentState.ts   | 123 +++++++++
 superset-frontend/src/core/storage/index.ts        |   1 +
 .../src/core/storage/persistentState.ts            |  80 ++++++
 .../src/extensions/ExtensionContext.ts             |   7 +-
 superset/core/api/core_api_injection.py            |   7 +
 superset/extensions/context.py                     |   8 +
 superset/extensions/storage/api.py                 | 158 ++++++++++++
 .../extensions/storage/persistent_state_dao.py     | 277 +++++++++++++++++++++
 .../extensions/storage/persistent_state_impl.py    | 165 ++++++++++++
 .../extensions/storage/persistent_state_model.py   | 126 ++++++++++
 ...-00_e5f6a7b8c9d0_add_extension_storage_table.py | 107 ++++++++
 .../extensions/storage/test_persistent_state.py    | 169 +++++++++++++
 .../storage/test_persistent_state_dao.py           | 217 ++++++++++++++++
 18 files changed, 1680 insertions(+), 7 deletions(-)

diff --git a/docs/developer_docs/extensions/storage.md b/docs/developer_docs/extensions/storage.md
index 7a0e84235db..596d378435d 100644
--- a/docs/developer_docs/extensions/storage.md
+++ b/docs/developer_docs/extensions/storage.md
@@ -34,6 +34,7 @@ Each extension receives its own isolated storage namespace. When Superset loads
 | ---- | ----------------- | ------------------------------------------ | -------------------------------------- |
 | 1    | Browser storage   | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms |
 | 2    | Server-side cache | `ctx.storage.ephemeral`                    | Job progress, temporary results        |
+| 3    | Database          | `ctx.storage.persistent`                   | User preferences, durable config       |
 
 ## Tier 1: Local State
 
@@ -193,6 +194,79 @@ result = ctx.storage.ephemeral.shared.get('shared_result')
 - Subject to cache eviction under memory pressure
 - TTL-based expiration (data disappears after timeout)
 
+## Tier 3: Persistent State
+
+Database-backed storage that survives server restarts, cache evictions, and browser clears. Use for any data that must not be lost.
+
+### Frontend Usage
+
+```typescript
+import { getContext } from '@apache-superset/core/extensions';
+
+const ctx = getContext();
+
+// Store user preferences
+await ctx.storage.persistent.set('preferences', { theme: 'dark', locale: 'en' });
+
+// Retrieve
+const prefs = await ctx.storage.persistent.get('preferences');
+
+// Remove
+await ctx.storage.persistent.remove('preferences');
+```
+
+### Backend Usage
+
+```python
+from superset_core.extensions.context import get_context
+
+ctx = get_context()
+
+# Store user preferences
+ctx.storage.persistent.set('preferences', {'theme': 'dark', 'locale': 'en'})
+
+# Retrieve
+prefs = ctx.storage.persistent.get('preferences')
+
+# Remove
+ctx.storage.persistent.remove('preferences')
+```
+
+### Shared State
+
+For data that should be visible to all users of the extension:
+
+```typescript
+import { getContext } from '@apache-superset/core/extensions';
+
+const ctx = getContext();
+
+await ctx.storage.persistent.shared.set('global_config', { version: 2 });
+const config = await ctx.storage.persistent.shared.get('global_config');
+```
+
+```python
+from superset_core.extensions.context import get_context
+
+ctx = get_context()
+
+ctx.storage.persistent.shared.set('global_config', {'version': 2})
+config = ctx.storage.persistent.shared.get('global_config')
+```
+
+### When to Use Tier 3
+
+- User preferences and settings
+- Extension configuration that must survive restarts
+- Saved state that needs to roam across devices and browsers
+- Any data where loss is unacceptable
+
+### Limitations
+
+- Higher latency than Tiers 1–2 (database round-trip per operation)
+- Subject to the 16 MB value size limit
+- Requires a database migration when first deployed
+
 ## Key Patterns
 
 All storage keys are automatically namespaced:
@@ -210,7 +284,9 @@ This ensures:
 
 ## Configuration
 
-Administrators can configure Tier 2 storage in `superset_config.py`:
+### Tier 2: Ephemeral Storage
+
+Administrators can configure the server-side cache backend in `superset_config.py`:
 
 ```python
 EXTENSIONS_STORAGE = {
@@ -224,3 +300,17 @@ EXTENSIONS_STORAGE = {
 ```
 
 For development, the default `SupersetMetastoreCache` stores data in the metadata database.
+
+### Tier 3: Persistent Storage
+
+Tier 3 values are stored in the `extension_storage` database table. The encryption infrastructure is in place (Fernet-based, keyed from `EXTENSION_STORAGE_ENCRYPTION_KEYS`), but values written through the standard storage API are stored unencrypted by default. Encryption is available at the DAO layer for backend extensions that call `ExtensionStorageDAO.set(..., is_encrypted=True)` directly.
+
+```python
+# Optional: override the encryption key(s) used for Tier 3 persistent storage.
+# Falls back to SECRET_KEY when not set.
+# Rotate keys by prepending the new key — all keys are tried on decryption.
+EXTENSION_STORAGE_ENCRYPTION_KEYS = [
+    "my-new-key-base64url-encoded",  # used for new writes
+    "my-old-key-base64url-encoded",  # kept for reading old values
+]
+```
diff --git a/superset-core/src/superset_core/extensions/storage/__init__.py b/superset-core/src/superset_core/extensions/storage/__init__.py
index c45b9b2ee09..2e8f23eca72 100644
--- a/superset-core/src/superset_core/extensions/storage/__init__.py
+++ b/superset-core/src/superset_core/extensions/storage/__init__.py
@@ -50,10 +50,13 @@ Usage:
     ephemeral_state.shared.get('job_progress')
     ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600)
 
-    # Future: Persistent state
-    # from superset_core.extensions.storage import persistent_state
-    # persistent_state.get('config')
-    # persistent_state.for_resource('dashboard', uuid).get('settings')
+    # Tier 3: Persistent state
+    from superset_core.extensions.storage import persistent_state
+    persistent_state.get('config')
+    persistent_state.set('config', {'version': 2})
 """
 
-from superset_core.extensions.storage import ephemeral_state  # noqa: F401
+from superset_core.extensions.storage import (
+    ephemeral_state,  # noqa: F401
+    persistent_state,  # noqa: F401
+)
diff --git a/superset-core/src/superset_core/extensions/storage/persistent_state.py b/superset-core/src/superset_core/extensions/storage/persistent_state.py
new file mode 100644
index 00000000000..7a8f89ea352
--- /dev/null
+++ b/superset-core/src/superset_core/extensions/storage/persistent_state.py
@@ -0,0 +1,129 @@
+# 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 State API for superset-core extensions (Tier 3 Storage).
+
+Provides durable KV storage backed by a dedicated database table.
+Data survives server restarts, cache evictions, and browser clears.
+Suitable for user preferences, saved state, and any data that must not be lost.
+
+Host implementations will replace these functions during initialization
+with concrete implementations providing actual functionality.
+
+Database keys are namespaced automatically:
+- User-scoped (default): (extension_id, user_id, key)
+- Shared (global): (extension_id, null, key)
+
+Usage:
+    from superset_core.extensions.storage import persistent_state
+
+    # User-scoped state (default - private to current user)
+    persistent_state.get('preferences')
+    persistent_state.set('preferences', {'theme': 'dark'})
+    persistent_state.remove('preferences')
+
+    # Shared state (explicit opt-in - visible to all users)
+    persistent_state.shared.get('global_config')
+    persistent_state.shared.set('global_config', {'version': 2})
+    persistent_state.shared.remove('global_config')
+"""
+
+from typing import Any, Protocol
+
+
+class PersistentStateAccessor(Protocol):
+    """Protocol for scoped persistent state access."""
+
+    def get(self, key: str) -> Any:
+        """Get a value from persistent state."""
+        ...
+
+    def set(self, key: str, value: Any) -> None:
+        """Set a value in persistent state."""
+        ...
+
+    def remove(self, key: str) -> None:
+        """Remove a value from persistent state."""
+        ...
+
+
+def get(key: str) -> Any:
+    """
+    Get a value from user-scoped persistent state.
+
+    Data is automatically scoped to the current authenticated user.
+    Other users cannot see or modify this data.
+
+    Host implementations will replace this function during initialization
+    with a concrete implementation providing actual functionality.
+
+    :param key: The key to retrieve.
+    :returns: The stored value, or None if not found.
+    """
+    raise NotImplementedError("Function will be replaced during initialization")
+
+
+def set(key: str, value: Any) -> None:
+    """
+    Set a value in user-scoped persistent state.
+
+    Data is automatically scoped to the current authenticated user.
+    Other users cannot see or modify this data.
+    Data persists indefinitely until explicitly removed.
+
+    Host implementations will replace this function during initialization
+    with a concrete implementation providing actual functionality.
+
+    :param key: The key to store.
+    :param value: The value to store (must be JSON-serializable).
+    """
+    raise NotImplementedError("Function will be replaced during initialization")
+
+
+def remove(key: str) -> None:
+    """
+    Remove a value from user-scoped persistent state.
+
+    Data is automatically scoped to the current authenticated user.
+
+    Host implementations will replace this function during initialization
+    with a concrete implementation providing actual functionality.
+
+    :param key: The key to remove.
+    """
+    raise NotImplementedError("Function will be replaced during initialization")
+
+
+class _SharedStub:
+    """Stub for shared accessor that raises NotImplementedError on any operation."""
+
+    def get(self, key: str) -> Any:
+        raise NotImplementedError("Accessor will be replaced during initialization")
+
+    def set(self, key: str, value: Any) -> None:
+        raise NotImplementedError("Accessor will be replaced during initialization")
+
+    def remove(self, key: str) -> None:
+        raise NotImplementedError("Accessor will be replaced during initialization")
+
+
+#: Shared (global) persistent state accessor.
+#: Data stored via this accessor is visible to all users of the extension.
+#: WARNING: Do not store user-specific or sensitive data here.
+#: Host implementations will replace this during initialization.
+shared: PersistentStateAccessor = _SharedStub()
diff --git a/superset-frontend/packages/superset-core/src/extensions/index.ts b/superset-frontend/packages/superset-core/src/extensions/index.ts
index 0bcd477f400..4f73fa5c37a 100644
--- a/superset-frontend/packages/superset-core/src/extensions/index.ts
+++ b/superset-frontend/packages/superset-core/src/extensions/index.ts
@@ -73,6 +73,13 @@ export interface ExtensionStorage {
    * Use `.shared` for data visible to all users.
    */
   ephemeral: StorageTier;
+
+  /**
+   * Durable database-backed storage (Tier 3).
+   * Data survives server restarts and cache evictions.
+   * Use `.shared` for data visible to all users.
+   */
+  persistent: StorageTier;
 }
 
 /**
diff --git a/superset-frontend/packages/superset-core/src/storage/index.ts b/superset-frontend/packages/superset-core/src/storage/index.ts
index 8f8a0ee671c..611a74f9a5d 100644
--- a/superset-frontend/packages/superset-core/src/storage/index.ts
+++ b/superset-frontend/packages/superset-core/src/storage/index.ts
@@ -56,4 +56,5 @@
 export * as localState from './localState';
 export * as sessionState from './sessionState';
 export * as ephemeralState from './ephemeralState';
+export * as persistentState from './persistentState';
 export * from './types';
diff --git a/superset-frontend/packages/superset-core/src/storage/persistentState.ts b/superset-frontend/packages/superset-core/src/storage/persistentState.ts
new file mode 100644
index 00000000000..2a7dbd86204
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/storage/persistentState.ts
@@ -0,0 +1,123 @@
+/**
+ * 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 type { JsonValue, StorageAccessor } from './types';
+
+/**
+ * @fileoverview Persistent State API for Superset extensions (Tier 3 Storage).
+ *
+ * Provides durable KV storage backed by a dedicated database table.
+ * Data survives server restarts, cache evictions, and browser clears.
+ * Suitable for user preferences, saved state, and any data that must
+ * not be lost.
+ *
+ * By default, all operations are user-scoped (private to the current user).
+ * Use `shared` to access state that is visible to all users of the extension.
+ *
+ * Database keys are namespaced automatically:
+ * - User-scoped (default): (extension_id, user_id, key)
+ * - Shared (global): (extension_id, null, key)
+ *
+ * @example
+ * ```typescript
+ * import { persistentState } from '@apache-superset/core/storage';
+ *
+ * // User-scoped state (default - private to current user)
+ * const prefs = await persistentState.get('preferences');
+ * await persistentState.set('preferences', { theme: 'dark', locale: 'en' });
+ * await persistentState.remove('preferences');
+ *
+ * // Shared state (explicit opt-in - visible to all users)
+ * const config = await persistentState.shared.get('global_config');
+ * await persistentState.shared.set('global_config', { version: 2 });
+ * await persistentState.shared.remove('global_config');
+ * ```
+ */
+
+/**
+ * Get a value from user-scoped persistent state.
+ *
+ * Data is automatically scoped to the current authenticated user.
+ * Other users cannot see or modify this data.
+ *
+ * @param key The key to retrieve.
+ * @returns The stored value, or null if not found.
+ *
+ * @example
+ * ```typescript
+ * const prefs = await persistentState.get('preferences');
+ * if (prefs !== null) {
+ *   applyPreferences(prefs);
+ * }
+ * ```
+ */
+export declare function get(key: string): Promise<JsonValue | null>;
+
+/**
+ * Set a value in user-scoped persistent state.
+ *
+ * Data is automatically scoped to the current authenticated user.
+ * Other users cannot see or modify this data.
+ * Data persists indefinitely until explicitly removed.
+ *
+ * @param key The key to store.
+ * @param value The value to store (must be JSON-serializable).
+ *
+ * @example
+ * ```typescript
+ * await persistentState.set('preferences', { theme: 'dark', locale: 'en' });
+ * ```
+ */
+export declare function set(key: string, value: JsonValue): Promise<void>;
+
+/**
+ * Remove a value from user-scoped persistent state.
+ *
+ * @param key The key to remove.
+ *
+ * @example
+ * ```typescript
+ * await persistentState.remove('preferences');
+ * ```
+ */
+export declare function remove(key: string): Promise<void>;
+
+/**
+ * Shared (global) persistent state accessor.
+ *
+ * Accessor for state that is shared across all users of the extension.
+ * Use this for extension-wide configuration, shared datasets, or any
+ * data that should be accessible to all users regardless of identity.
+ *
+ * WARNING: Data stored via shared is visible to all users of the extension.
+ * Do not store user-specific or sensitive data here.
+ *
+ * @example
+ * ```typescript
+ * // Read shared extension config
+ * const config = await persistentState.shared.get('global_config');
+ *
+ * // Update shared config (typically admin-only)
+ * await persistentState.shared.set('global_config', { version: 2 });
+ *
+ * // Remove shared config entry
+ * await persistentState.shared.remove('global_config');
+ * ```
+ */
+export declare const shared: StorageAccessor;
diff --git a/superset-frontend/src/core/storage/index.ts b/superset-frontend/src/core/storage/index.ts
index 8fc53840bed..b26f9a411f8 100644
--- a/superset-frontend/src/core/storage/index.ts
+++ b/superset-frontend/src/core/storage/index.ts
@@ -24,3 +24,4 @@
 
 export { createBrowserStorage } from './localState';
 export { createEphemeralState } from './ephemeralState';
+export { createPersistentState } from './persistentState';
diff --git a/superset-frontend/src/core/storage/persistentState.ts b/superset-frontend/src/core/storage/persistentState.ts
new file mode 100644
index 00000000000..99354ff351b
--- /dev/null
+++ b/superset-frontend/src/core/storage/persistentState.ts
@@ -0,0 +1,80 @@
+/**
+ * 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 { type storage as StorageTypes } from '@apache-superset/core';
+import { SupersetClient } from '@superset-ui/core';
+
+/**
+ * Create persistent state (database-backed) bound to an extension ID.
+ */
+export function createPersistentState(
+  extensionId: string,
+): typeof StorageTypes.persistentState {
+  const MAX_KEY_LENGTH = 255;
+
+  const buildUrl = (key: string, shared?: boolean): string => {
+    if (key.length > MAX_KEY_LENGTH) {
+      throw new Error(
+        `Persistent storage key must be ${MAX_KEY_LENGTH} characters or less.`,
+      );
+    }
+    const basePath = '/api/v1/extensions/storage/persistent';
+    const encodedId = encodeURIComponent(extensionId);
+    const encodedKey = encodeURIComponent(key);
+    const url = `${basePath}/${encodedId}/${encodedKey}`;
+    return shared ? `${url}?shared=true` : url;
+  };
+
+  const shared: StorageTypes.StorageAccessor = {
+    get: async (key: string) => {
+      const response = await SupersetClient.get({
+        endpoint: buildUrl(key, true),
+      });
+      return response.json?.result ?? null;
+    },
+    set: async (key: string, value: StorageTypes.JsonValue) => {
+      await SupersetClient.put({
+        endpoint: buildUrl(key, true),
+        body: JSON.stringify({ value }),
+        headers: { 'Content-Type': 'application/json' },
+      });
+    },
+    remove: async (key: string) => {
+      await SupersetClient.delete({ endpoint: buildUrl(key, true) });
+    },
+  };
+
+  return {
+    get: async (key: string) => {
+      const response = await SupersetClient.get({ endpoint: buildUrl(key) });
+      return response.json?.result ?? null;
+    },
+    set: async (key: string, value: StorageTypes.JsonValue) => {
+      await SupersetClient.put({
+        endpoint: buildUrl(key),
+        body: JSON.stringify({ value }),
+        headers: { 'Content-Type': 'application/json' },
+      });
+    },
+    remove: async (key: string) => {
+      await SupersetClient.delete({ endpoint: buildUrl(key) });
+    },
+    shared,
+  };
+}
diff --git a/superset-frontend/src/extensions/ExtensionContext.ts b/superset-frontend/src/extensions/ExtensionContext.ts
index 1feea23b373..e3d7a4726f4 100644
--- a/superset-frontend/src/extensions/ExtensionContext.ts
+++ b/superset-frontend/src/extensions/ExtensionContext.ts
@@ -20,7 +20,11 @@ import type {
   extensions as extensionsApi,
   common,
 } from '@apache-superset/core';
-import { createBrowserStorage, createEphemeralState } from 'src/core/storage';
+import {
+  createBrowserStorage,
+  createEphemeralState,
+  createPersistentState,
+} from 'src/core/storage';
 
 type ExtensionContextType = extensionsApi.ExtensionContext;
 type Extension = common.Extension;
@@ -44,6 +48,7 @@ class ExtensionContext implements ExtensionContextType {
         local: createBrowserStorage(localStorage, id),
         session: createBrowserStorage(sessionStorage, id),
         ephemeral: createEphemeralState(id),
+        persistent: createPersistentState(id),
       };
     }
     return this._storage;
diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py
index fdbce562ea8..93def181b06 100644
--- a/superset/core/api/core_api_injection.py
+++ b/superset/core/api/core_api_injection.py
@@ -232,8 +232,10 @@ def inject_storage_implementations() -> None:
     implementations from Superset.
     """
     import superset_core.extensions.storage.ephemeral_state as core_ephemeral_state
+    import superset_core.extensions.storage.persistent_state as core_persistent_state
 
     from superset.extensions.storage.ephemeral_state import EphemeralStateImpl
+    from superset.extensions.storage.persistent_state_impl import PersistentStateImpl
 
     # Replace abstract functions with concrete implementations
     core_ephemeral_state.get = EphemeralStateImpl.get
@@ -241,6 +243,11 @@ def inject_storage_implementations() -> None:
     core_ephemeral_state.remove = EphemeralStateImpl.remove
     core_ephemeral_state.shared = EphemeralStateImpl.shared
 
+    core_persistent_state.get = PersistentStateImpl.get
+    core_persistent_state.set = PersistentStateImpl.set
+    core_persistent_state.remove = PersistentStateImpl.remove
+    core_persistent_state.shared = PersistentStateImpl.shared
+
 
 def inject_extension_context() -> None:
     """
diff --git a/superset/extensions/context.py b/superset/extensions/context.py
index b299d949bac..8748ddaf808 100644
--- a/superset/extensions/context.py
+++ b/superset/extensions/context.py
@@ -44,6 +44,14 @@ class ExtensionStorage:
 
         return EphemeralStateImpl
 
+    @property
+    def persistent(self) -> Any:
+        from superset.extensions.storage.persistent_state_impl import (
+            PersistentStateImpl,
+        )
+
+        return PersistentStateImpl
+
 
 class ConcreteExtensionContext:
     """Concrete implementation of ExtensionContext for the host."""
diff --git a/superset/extensions/storage/api.py b/superset/extensions/storage/api.py
index ea67105c945..15fd9cdb5ce 100644
--- a/superset/extensions/storage/api.py
+++ b/superset/extensions/storage/api.py
@@ -34,8 +34,11 @@ from flask.wrappers import Response
 from flask_appbuilder.api import BaseApi, expose, protect, safe
 
 from superset.extensions import cache_manager
+from superset.extensions.storage.persistent_state_dao import ExtensionStorageDAO
 from superset.extensions.types import LoadedExtension
 from superset.extensions.utils import get_extensions
+from superset.utils import json
+from superset.utils.decorators import transaction
 
 # Key separator
 SEPARATOR = ":"
@@ -264,3 +267,158 @@ class ExtensionStorageRestApi(BaseApi):
         cache_manager.extension_ephemeral_state_cache.delete(cache_key)
 
         return self.response(200, message="Value deleted successfully")
+
+    @protect()
+    @safe
+    @expose("/persistent/<extension_id>/<key>", methods=("GET",))
+    def get_persistent(self, extension_id: str, key: str, **kwargs: Any) -> Response:
+        """Get a value from persistent state.
+        ---
+        get:
+          summary: Get a value from persistent state
+          parameters:
+          - in: path
+            name: extension_id
+            schema:
+              type: string
+            required: true
+            description: Extension ID (publisher.name)
+          - in: path
+            name: key
+            schema:
+              type: string
+            required: true
+            description: Storage key
+          - in: query
+            name: shared
+            schema:
+              type: boolean
+            required: false
+            description: If true, read from shared state visible to all users
+          responses:
+            200:
+              description: Value retrieved successfully
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        description: The stored value
+            404:
+              description: Extension not found
+        """
+        extension = _get_extension_or_404(extension_id)
+        if not extension:
+            return self.response_404("Extension not found")
+
+        shared = request.args.get("shared", "false").lower() == "true"
+        user_fk = None if shared else g.user.id
+        raw = ExtensionStorageDAO.get_value(extension_id, key, user_fk=user_fk)
+        value = json.loads(raw) if raw is not None else None
+
+        return self.response(200, result=value)
+
+    @protect()
+    @safe
+    @expose("/persistent/<extension_id>/<key>", methods=("PUT",))
+    @transaction()
+    def set_persistent(self, extension_id: str, key: str, **kwargs: Any) -> Response:
+        """Set a value in persistent state.
+        ---
+        put:
+          summary: Set a value in persistent state
+          parameters:
+          - in: path
+            name: extension_id
+            schema:
+              type: string
+            required: true
+            description: Extension ID (publisher.name)
+          - in: path
+            name: key
+            schema:
+              type: string
+            required: true
+            description: Storage key
+          - in: query
+            name: shared
+            schema:
+              type: boolean
+            required: false
+            description: If true, store as shared state visible to all users
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    value:
+                      description: The value to store (must be JSON-serializable)
+          responses:
+            200:
+              description: Value stored successfully
+            400:
+              description: Invalid request body
+            404:
+              description: Extension not found
+        """
+        extension = _get_extension_or_404(extension_id)
+        if not extension:
+            return self.response_404("Extension not found")
+
+        body = request.get_json(silent=True) or {}
+        if "value" not in body:
+            return self.response_400("Request body must contain 'value' field")
+
+        shared = request.args.get("shared", "false").lower() == "true"
+        user_fk = None if shared else g.user.id
+        value_bytes = json.dumps(body["value"]).encode()
+        ExtensionStorageDAO.set(extension_id, key, value_bytes, user_fk=user_fk)
+
+        return self.response(200, message="Value stored successfully")
+
+    @protect()
+    @safe
+    @expose("/persistent/<extension_id>/<key>", methods=("DELETE",))
+    @transaction()
+    def delete_persistent(self, extension_id: str, key: str, **kwargs: Any) -> Response:
+        """Delete a value from persistent state.
+        ---
+        delete:
+          summary: Delete a value from persistent state
+          parameters:
+          - in: path
+            name: extension_id
+            schema:
+              type: string
+            required: true
+            description: Extension ID (publisher.name)
+          - in: path
+            name: key
+            schema:
+              type: string
+            required: true
+            description: Storage key
+          - in: query
+            name: shared
+            schema:
+              type: boolean
+            required: false
+            description: If true, delete from shared state
+          responses:
+            200:
+              description: Value deleted successfully
+            404:
+              description: Extension not found
+        """
+        extension = _get_extension_or_404(extension_id)
+        if not extension:
+            return self.response_404("Extension not found")
+
+        shared = request.args.get("shared", "false").lower() == "true"
+        user_fk = None if shared else g.user.id
+        ExtensionStorageDAO.delete(extension_id, key, user_fk=user_fk)
+
+        return self.response(200, message="Value deleted successfully")
diff --git a/superset/extensions/storage/persistent_state_dao.py b/superset/extensions/storage/persistent_state_dao.py
new file mode 100644
index 00000000000..1c01b807973
--- /dev/null
+++ b/superset/extensions/storage/persistent_state_dao.py
@@ -0,0 +1,277 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import logging
+
+from cryptography.fernet import Fernet, InvalidToken, MultiFernet
+from flask import current_app
+from sqlalchemy import and_
+
+from superset import db
+from superset.extensions.storage.persistent_state_model import ExtensionStorage
+
+logger = logging.getLogger(__name__)
+
+
+def _key_to_fernet(raw_key: str | bytes) -> Fernet:
+    """Derive a Fernet instance from an arbitrary-length secret string.
+
+    SHA-256 compresses the key to exactly 32 bytes, which are then
+    base64url-encoded to satisfy Fernet's key format requirement.
+    """
+    if isinstance(raw_key, str):
+        raw_key = raw_key.encode()
+    return Fernet(base64.urlsafe_b64encode(hashlib.sha256(raw_key).digest()))
+
+
+def _fernet() -> MultiFernet:
+    """Return a MultiFernet built from EXTENSION_STORAGE_ENCRYPTION_KEYS.
+
+    Falls back to SECRET_KEY when the config list is absent.  The first key in
+    the list is used for new encryptions; all keys are tried on decryption,
+    enabling zero-downtime rotation: add the new key at the front of
+    EXTENSION_STORAGE_ENCRYPTION_KEYS, then run ``superset rotate-extension-
+    storage-keys`` to re-encrypt every row with the new key.
+    """
+    raw_keys: list[str | bytes] = current_app.config.get(
+        "EXTENSION_STORAGE_ENCRYPTION_KEYS"
+    ) or [current_app.config["SECRET_KEY"]]
+    return MultiFernet([_key_to_fernet(k) for k in raw_keys])
+
+
+def _scope_filter(
+    extension_id: str,
+    key: str,
+    user_fk: int | None = None,
+    resource_type: str | None = None,
+    resource_uuid: str | None = None,
+) -> list[object]:
+    """Build the SQLAlchemy filter list for a scoped lookup."""
+    filters: list[object] = [
+        ExtensionStorage.extension_id == extension_id,
+        ExtensionStorage.key == key,
+    ]
+    if user_fk is None:
+        filters.append(ExtensionStorage.user_fk.is_(None))
+    else:
+        filters.append(ExtensionStorage.user_fk == user_fk)
+    if resource_type is None:
+        filters.append(ExtensionStorage.resource_type.is_(None))
+    else:
+        filters.append(ExtensionStorage.resource_type == resource_type)
+    if resource_uuid is None:
+        filters.append(ExtensionStorage.resource_uuid.is_(None))
+    else:
+        filters.append(ExtensionStorage.resource_uuid == resource_uuid)
+    return filters
+
+
+class ExtensionStorageDAO:
+    """Persistent key-value store for extensions.
+
+    Provides scoped get/set/delete and list operations covering the three
+    storage scopes defined by the Tier 3 proposal:
+
+    * Global scope      — user_fk=None, resource_type=None
+    * User scope        — user_fk=<id>, resource_type=None
+    * Resource scope    — resource_type + resource_uuid set
+    """
+
+    # ── Read ─────────────────────────────────────────────────────────────────
+
+    @staticmethod
+    def get(
+        extension_id: str,
+        key: str,
+        user_fk: int | None = None,
+        resource_type: str | None = None,
+        resource_uuid: str | None = None,
+    ) -> ExtensionStorage | None:
+        """Return the raw storage entry. The value field may be encrypted."""
+        entry = (
+            db.session.query(ExtensionStorage)
+            .filter(
+                and_(
+                    *_scope_filter(
+                        extension_id, key, user_fk, resource_type, resource_uuid
+                    )
+                )
+            )
+            .first()
+        )
+        return entry
+
+    @staticmethod
+    def get_value(
+        extension_id: str,
+        key: str,
+        user_fk: int | None = None,
+        resource_type: str | None = None,
+        resource_uuid: str | None = None,
+    ) -> bytes | None:
+        """Return the raw (decrypted) value bytes, or None if not found."""
+        entry = ExtensionStorageDAO.get(
+            extension_id, key, user_fk, resource_type, resource_uuid
+        )
+        if entry is None:
+            return None
+        if entry.is_encrypted:
+            try:
+                return _fernet().decrypt(entry.value)
+            except InvalidToken:
+                logger.error(
+                    "Failed to decrypt extension storage value for "
+                    "extension_id=%s key=%s — possible key rotation issue",
+                    extension_id,
+                    key,
+                )
+                return None
+        return entry.value
+
+    # ── Write (upsert) ────────────────────────────────────────────────────────
+
+    @staticmethod
+    def set(
+        extension_id: str,
+        key: str,
+        value: bytes,
+        value_type: str = "application/json",
+        user_fk: int | None = None,
+        resource_type: str | None = None,
+        resource_uuid: str | None = None,
+        category: str | None = None,
+        description: str | None = None,
+        is_encrypted: bool = False,
+    ) -> ExtensionStorage:
+        """Upsert a storage entry.  Encrypts value when is_encrypted=True."""
+        stored_value = _fernet().encrypt(value) if is_encrypted else value
+
+        entry = (
+            db.session.query(ExtensionStorage)
+            .filter(
+                and_(
+                    *_scope_filter(
+                        extension_id, key, user_fk, resource_type, resource_uuid
+                    )
+                )
+            )
+            .first()
+        )
+        if entry is not None:
+            entry.value = stored_value
+            entry.value_type = value_type
+            entry.category = category
+            entry.description = description
+            entry.is_encrypted = is_encrypted
+        else:
+            entry = ExtensionStorage(
+                extension_id=extension_id,
+                key=key,
+                value=stored_value,
+                value_type=value_type,
+                user_fk=user_fk,
+                resource_type=resource_type,
+                resource_uuid=resource_uuid,
+                category=category,
+                description=description,
+                is_encrypted=is_encrypted,
+            )
+            db.session.add(entry)
+        db.session.flush()
+        return entry
+
+    # ── Delete ────────────────────────────────────────────────────────────────
+
+    @staticmethod
+    def delete(
+        extension_id: str,
+        key: str,
+        user_fk: int | None = None,
+        resource_type: str | None = None,
+        resource_uuid: str | None = None,
+    ) -> bool:
+        """Delete an entry. Returns True if a row was removed."""
+        entry = (
+            db.session.query(ExtensionStorage)
+            .filter(
+                and_(
+                    *_scope_filter(
+                        extension_id, key, user_fk, resource_type, resource_uuid
+                    )
+                )
+            )
+            .first()
+        )
+        if entry is None:
+            return False
+        db.session.delete(entry)
+        db.session.flush()
+        return True
+
+    # ── List ──────────────────────────────────────────────────────────────────
+
+    @staticmethod
+    def list_global(
+        extension_id: str,
+        category: str | None = None,
+    ) -> list[ExtensionStorage]:
+        """List all global (user_fk=NULL, resource_type=NULL) entries."""
+        q = db.session.query(ExtensionStorage).filter(
+            ExtensionStorage.extension_id == extension_id,
+            ExtensionStorage.user_fk.is_(None),
+            ExtensionStorage.resource_type.is_(None),
+        )
+        if category is not None:
+            q = q.filter(ExtensionStorage.category == category)
+        return q.order_by(ExtensionStorage.key).all()
+
+    @staticmethod
+    def list_user(
+        extension_id: str,
+        user_fk: int,
+        category: str | None = None,
+    ) -> list[ExtensionStorage]:
+        """List all user-scoped entries (resource_type=NULL)."""
+        q = db.session.query(ExtensionStorage).filter(
+            ExtensionStorage.extension_id == extension_id,
+            ExtensionStorage.user_fk == user_fk,
+            ExtensionStorage.resource_type.is_(None),
+        )
+        if category is not None:
+            q = q.filter(ExtensionStorage.category == category)
+        return q.order_by(ExtensionStorage.key).all()
+
+    @staticmethod
+    def list_resource(
+        extension_id: str,
+        resource_type: str,
+        resource_uuid: str,
+        category: str | None = None,
+    ) -> list[ExtensionStorage]:
+        """List all entries linked to a specific resource."""
+        q = db.session.query(ExtensionStorage).filter(
+            ExtensionStorage.extension_id == extension_id,
+            ExtensionStorage.resource_type == resource_type,
+            ExtensionStorage.resource_uuid == resource_uuid,
+        )
+        if category is not None:
+            q = q.filter(ExtensionStorage.category == category)
+        return q.order_by(ExtensionStorage.key).all()
diff --git a/superset/extensions/storage/persistent_state_impl.py b/superset/extensions/storage/persistent_state_impl.py
new file mode 100644
index 00000000000..205692019d0
--- /dev/null
+++ b/superset/extensions/storage/persistent_state_impl.py
@@ -0,0 +1,165 @@
+# 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.
+
+"""
+Host implementation for Persistent State (Tier 3 Storage).
+
+Provides the concrete database-backed implementation that is injected into
+superset_core.extensions.storage.persistent_state at startup.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from flask import g
+
+from superset.extensions.context import get_current_extension_context
+from superset.extensions.storage.persistent_state_dao import ExtensionStorageDAO
+from superset.utils import json
+from superset.utils.decorators import transaction
+
+
+def _get_extension_id() -> str:
+    """Get the current extension ID from context."""
+    context = get_current_extension_context()
+    if context is None:
+        raise RuntimeError(
+            "persistent_state can only be used within an extension context. "
+            "Ensure this code is being executed during extension loading or "
+            "within an extension API request handler."
+        )
+    return context.manifest.id
+
+
+def _get_current_user_id() -> int:
+    """Get the current authenticated user's ID."""
+    user = getattr(g, "user", None)
+    if user is None or not hasattr(user, "id"):
+        raise RuntimeError(
+            "persistent_state requires an authenticated user. "
+            "Ensure the request has been authenticated."
+        )
+    return user.id
+
+
+def _decode(raw: bytes | None) -> Any:
+    """Decode stored bytes back to a Python value."""
+    if raw is None:
+        return None
+    return json.loads(raw)
+
+
+def _encode(value: Any) -> bytes:
+    """Encode a Python value for database storage."""
+    return json.dumps(value).encode()
+
+
+class SharedPersistentStateAccessor:
+    """
+    Accessor for shared (global) persistent state.
+
+    Data stored via this accessor is visible to all users of the extension.
+    Extension ID is resolved lazily on each operation from the current context.
+    """
+
+    def get(self, key: str) -> Any:
+        """
+        Get a value from shared persistent state.
+
+        :param key: The key to retrieve.
+        :returns: The stored value, or None if not found.
+        """
+        extension_id = _get_extension_id()
+        raw = ExtensionStorageDAO.get_value(extension_id, key, user_fk=None)
+        return _decode(raw)
+
+    @transaction()
+    def set(self, key: str, value: Any) -> None:
+        """
+        Set a value in shared persistent state.
+
+        :param key: The key to store.
+        :param value: The value to store (must be JSON-serializable).
+        """
+        extension_id = _get_extension_id()
+        ExtensionStorageDAO.set(extension_id, key, _encode(value), user_fk=None)
+
+    @transaction()
+    def remove(self, key: str) -> None:
+        """
+        Remove a value from shared persistent state.
+
+        :param key: The key to remove.
+        """
+        extension_id = _get_extension_id()
+        ExtensionStorageDAO.delete(extension_id, key, user_fk=None)
+
+
+class PersistentStateImpl:
+    """
+    Host implementation for persistent state operations.
+
+    This class provides the concrete implementation that is injected into
+    superset_core.extensions.storage.persistent_state.
+
+    By default, all operations are user-scoped (private to the current user).
+    Use `shared` to access state that is visible to all users.
+    """
+
+    @staticmethod
+    def get(key: str) -> Any:
+        """
+        Get a value from user-scoped persistent state.
+
+        :param key: The key to retrieve.
+        :returns: The stored value, or None if not found.
+        """
+        extension_id = _get_extension_id()
+        user_id = _get_current_user_id()
+        raw = ExtensionStorageDAO.get_value(extension_id, key, user_fk=user_id)
+        return _decode(raw)
+
+    @staticmethod
+    @transaction()
+    def set(key: str, value: Any) -> None:
+        """
+        Set a value in user-scoped persistent state.
+
+        :param key: The key to store.
+        :param value: The value to store (must be JSON-serializable).
+        """
+        extension_id = _get_extension_id()
+        user_id = _get_current_user_id()
+        ExtensionStorageDAO.set(extension_id, key, _encode(value), user_fk=user_id)
+
+    @staticmethod
+    @transaction()
+    def remove(key: str) -> None:
+        """
+        Remove a value from user-scoped persistent state.
+
+        :param key: The key to remove.
+        """
+        extension_id = _get_extension_id()
+        user_id = _get_current_user_id()
+        ExtensionStorageDAO.delete(extension_id, key, user_fk=user_id)
+
+    #: Shared (global) persistent state accessor.
+    #: Data stored via this accessor is visible to all users of the extension.
+    #: WARNING: Do not store user-specific or sensitive data here.
+    shared: SharedPersistentStateAccessor = SharedPersistentStateAccessor()
diff --git a/superset/extensions/storage/persistent_state_model.py b/superset/extensions/storage/persistent_state_model.py
new file mode 100644
index 00000000000..9790956b618
--- /dev/null
+++ b/superset/extensions/storage/persistent_state_model.py
@@ -0,0 +1,126 @@
+# 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,
+    UniqueConstraint,
+)
+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",
+            ondelete="SET NULL",
+            name="fk_extension_storage_user_fk_ab_user",
+        ),
+        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__ = (
+        # Unique constraint prevents duplicate rows from concurrent writes
+        UniqueConstraint(
+            "extension_id",
+            "user_fk",
+            "resource_type",
+            "resource_uuid",
+            "key",
+            name="uq_extension_storage_scoped_key",
+        ),
+        # 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/migrations/versions/2026-04-07_12-00_e5f6a7b8c9d0_add_extension_storage_table.py b/superset/migrations/versions/2026-04-07_12-00_e5f6a7b8c9d0_add_extension_storage_table.py
new file mode 100644
index 00000000000..5f0088d145b
--- /dev/null
+++ b/superset/migrations/versions/2026-04-07_12-00_e5f6a7b8c9d0_add_extension_storage_table.py
@@ -0,0 +1,107 @@
+# 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-04-07 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"],
+            name="fk_extension_storage_user_fk_ab_user",
+            ondelete="SET NULL",
+        ),
+        sa.ForeignKeyConstraint(
+            ["created_by_fk"],
+            ["ab_user.id"],
+            name="fk_extension_storage_created_by_fk_ab_user",
+            ondelete="SET NULL",
+        ),
+        sa.ForeignKeyConstraint(
+            ["changed_by_fk"],
+            ["ab_user.id"],
+            name="fk_extension_storage_changed_by_fk_ab_user",
+            ondelete="SET NULL",
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("uuid"),
+        sa.UniqueConstraint(
+            "extension_id",
+            "user_fk",
+            "resource_type",
+            "resource_uuid",
+            "key",
+            name="uq_extension_storage_scoped_key",
+        ),
+    )
+    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/extensions/storage/test_persistent_state.py b/tests/unit_tests/extensions/storage/test_persistent_state.py
new file mode 100644
index 00000000000..f84629afc49
--- /dev/null
+++ b/tests/unit_tests/extensions/storage/test_persistent_state.py
@@ -0,0 +1,169 @@
+# 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.
+
+"""Tests for persistent state storage implementation."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask, g
+from superset_core.extensions.types import Manifest
+
+from superset.extensions.context import ConcreteExtensionContext, use_context
+from superset.extensions.storage.persistent_state_impl import (
+    PersistentStateImpl,
+    SharedPersistentStateAccessor,
+)
+from superset.utils import json
+
+
+@pytest.fixture
+def app() -> Flask:
+    """Create a minimal Flask app for testing."""
+    app = Flask(__name__)
+    app.config["TESTING"] = True
+    return app
+
+
+def _create_context(
+    publisher: str = "test-org", name: str = "test-ext"
+) -> ConcreteExtensionContext:
+    """Create test context with given extension identifiers."""
+    manifest = Manifest.model_validate(
+        {
+            "id": f"{publisher}.{name}",
+            "publisher": publisher,
+            "name": name,
+            "displayName": f"Test {name}",
+        }
+    )
+    return ConcreteExtensionContext(manifest)
+
+
+def _set_user(user_id: int) -> None:
+    """Set a mock user on Flask's g object."""
+    g.user = MagicMock(id=user_id)
+
+
+@patch("superset.db")
+def test_persistent_state_raises_without_context(
+    mock_db: MagicMock, app: Flask
+) -> None:
+    """PersistentStateImpl operations raise RuntimeError without extension context."""
+    with app.app_context():
+        _set_user(1)
+
+        with pytest.raises(RuntimeError, match="within an extension context"):
+            PersistentStateImpl.get("key")
+
+        with pytest.raises(RuntimeError, match="within an extension context"):
+            PersistentStateImpl.set("key", "value")
+
+        with pytest.raises(RuntimeError, match="within an extension context"):
+            PersistentStateImpl.remove("key")
+
+
+def test_persistent_state_raises_without_user(app: Flask) -> None:
+    """PersistentStateImpl operations raise RuntimeError without authenticated user."""
+    ctx = _create_context()
+
+    with app.app_context(), use_context(ctx):
+        with pytest.raises(RuntimeError, match="requires an authenticated user"):
+            PersistentStateImpl.get("key")
+
+
+@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
+def test_persistent_state_get_returns_value(mock_dao: MagicMock, app: Flask) -> None:
+    """PersistentStateImpl.get returns decoded value from DAO."""
+    ctx = _create_context()
+    stored = json.dumps({"theme": "dark"}).encode()
+    mock_dao.get_value.return_value = stored
+
+    with app.app_context(), use_context(ctx):
+        _set_user(42)
+        result = PersistentStateImpl.get("prefs")
+
+    mock_dao.get_value.assert_called_once_with("test-org.test-ext", "prefs", user_fk=42)
+    assert result == {"theme": "dark"}
+
+
+@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
+def test_persistent_state_get_returns_none_when_missing(
+    mock_dao: MagicMock, app: Flask
+) -> None:
+    """PersistentStateImpl.get returns None when key does not exist."""
+    ctx = _create_context()
+    mock_dao.get_value.return_value = None
+
+    with app.app_context(), use_context(ctx):
+        _set_user(42)
+        result = PersistentStateImpl.get("missing")
+
+    assert result is None
+
+
+@patch("superset.db")
+@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
+def test_persistent_state_set_encodes_value(
+    mock_dao: MagicMock, mock_db: MagicMock, app: Flask
+) -> None:
+    """PersistentStateImpl.set encodes value as JSON bytes."""
+    ctx = _create_context()
+
+    with app.app_context(), use_context(ctx):
+        _set_user(42)
+        PersistentStateImpl.set("prefs", {"theme": "dark"})
+
+    expected_bytes = json.dumps({"theme": "dark"}).encode()
+    mock_dao.set.assert_called_once_with(
+        "test-org.test-ext", "prefs", expected_bytes, user_fk=42
+    )
+
+
+@patch("superset.db")
+@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
+def test_persistent_state_remove_deletes_entry(
+    mock_dao: MagicMock, mock_db: MagicMock, app: Flask
+) -> None:
+    """PersistentStateImpl.remove calls DAO delete."""
+    ctx = _create_context()
+
+    with app.app_context(), use_context(ctx):
+        _set_user(42)
+        PersistentStateImpl.remove("prefs")
+
+    mock_dao.delete.assert_called_once_with("test-org.test-ext", "prefs", user_fk=42)
+
+
+@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
+def test_shared_accessor_uses_null_user_fk(mock_dao: MagicMock, app: Flask) -> None:
+    """SharedPersistentStateAccessor uses user_fk=None for global scope."""
+    ctx = _create_context()
+    mock_dao.get_value.return_value = json.dumps("shared_value").encode()
+
+    accessor = SharedPersistentStateAccessor()
+
+    with app.app_context(), use_context(ctx):
+        _set_user(42)
+        result = accessor.get("config")
+
+    mock_dao.get_value.assert_called_once_with(
+        "test-org.test-ext", "config", user_fk=None
+    )
+    assert result == "shared_value"
diff --git a/tests/unit_tests/extensions/storage/test_persistent_state_dao.py b/tests/unit_tests/extensions/storage/test_persistent_state_dao.py
new file mode 100644
index 00000000000..a0175c4a8e1
--- /dev/null
+++ b/tests/unit_tests/extensions/storage/test_persistent_state_dao.py
@@ -0,0 +1,217 @@
+# 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.
+
+"""Tests for ExtensionStorageDAO — encryption, scoping, and CRUD behavior."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from cryptography.fernet import InvalidToken
+from flask import Flask
+
+from superset.extensions.storage.persistent_state_dao import ExtensionStorageDAO
+
+
+@pytest.fixture
+def app() -> Flask:
+    """Create a minimal Flask app for testing."""
+    flask_app = Flask(__name__)
+    flask_app.config["TESTING"] = True
+    return flask_app
+
+
+# ── get / get_value ───────────────────────────────────────────────────────────
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_get_returns_none_when_not_found(mock_db: MagicMock, app: Flask) -> None:
+    """get() returns None when no entry exists for the given scope."""
+    mock_db.session.query.return_value.filter.return_value.first.return_value = None
+
+    with app.app_context():
+        result = ExtensionStorageDAO.get("my-ext", "key", user_fk=1)
+
+    assert result is None
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_get_value_returns_raw_bytes_for_unencrypted(
+    mock_db: MagicMock, app: Flask
+) -> None:
+    """get_value() returns raw bytes unchanged when the entry is not encrypted."""
+    entry = MagicMock()
+    entry.is_encrypted = False
+    entry.value = b'{"foo": "bar"}'
+    mock_db.session.query.return_value.filter.return_value.first.return_value = entry
+
+    with app.app_context():
+        result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
+
+    assert result == b'{"foo": "bar"}'
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_get_value_returns_none_when_not_found(
+    mock_db: MagicMock, app: Flask
+) -> None:
+    """get_value() returns None when the key does not exist."""
+    mock_db.session.query.return_value.filter.return_value.first.return_value = None
+
+    with app.app_context():
+        result = ExtensionStorageDAO.get_value("my-ext", "missing", user_fk=1)
+
+    assert result is None
+
+
+@patch("superset.extensions.storage.persistent_state_dao._fernet")
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_get_value_decrypts_encrypted_entry(
+    mock_db: MagicMock, mock_fernet_fn: MagicMock, app: Flask
+) -> None:
+    """get_value() decrypts the stored value for encrypted entries."""
+    entry = MagicMock()
+    entry.is_encrypted = True
+    entry.value = b"encrypted-bytes"
+    mock_db.session.query.return_value.filter.return_value.first.return_value = entry
+    mock_fernet_fn.return_value.decrypt.return_value = b'{"decrypted": true}'
+
+    with app.app_context():
+        result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
+
+    assert result == b'{"decrypted": true}'
+    mock_fernet_fn.return_value.decrypt.assert_called_once_with(b"encrypted-bytes")
+
+
+@patch("superset.extensions.storage.persistent_state_dao._fernet")
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_get_value_returns_none_on_invalid_token(
+    mock_db: MagicMock, mock_fernet_fn: MagicMock, app: Flask
+) -> None:
+    """get_value() returns None and logs an error when decryption fails."""
+    entry = MagicMock()
+    entry.is_encrypted = True
+    entry.value = b"corrupted"
+    mock_db.session.query.return_value.filter.return_value.first.return_value = entry
+    mock_fernet_fn.return_value.decrypt.side_effect = InvalidToken()
+
+    with app.app_context():
+        result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
+
+    assert result is None
+
+
+# ── set (upsert) ──────────────────────────────────────────────────────────────
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_set_creates_new_entry_when_absent(mock_db: MagicMock, app: Flask) -> None:
+    """set() adds a new entry when no existing entry is found."""
+    mock_db.session.query.return_value.filter.return_value.first.return_value = None
+
+    with app.app_context():
+        ExtensionStorageDAO.set("my-ext", "key", b'{"value": 1}', user_fk=1)
+
+    mock_db.session.add.assert_called_once()
+    mock_db.session.flush.assert_called_once()
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_set_updates_existing_entry(mock_db: MagicMock, app: Flask) -> None:
+    """set() updates in-place when an entry already exists (no duplicate row)."""
+    existing = MagicMock()
+    mock_db.session.query.return_value.filter.return_value.first.return_value = existing
+
+    with app.app_context():
+        ExtensionStorageDAO.set("my-ext", "key", b'{"new": true}', user_fk=1)
+
+    assert existing.value == b'{"new": true}'
+    mock_db.session.add.assert_not_called()
+    mock_db.session.flush.assert_called_once()
+
+
+@patch("superset.extensions.storage.persistent_state_dao._fernet")
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_set_encrypts_value_when_requested(
+    mock_db: MagicMock, mock_fernet_fn: MagicMock, app: Flask
+) -> None:
+    """set() encrypts value bytes and sets is_encrypted=True when requested."""
+    mock_db.session.query.return_value.filter.return_value.first.return_value = None
+    mock_fernet_fn.return_value.encrypt.return_value = b"ciphertext"
+
+    with app.app_context():
+        ExtensionStorageDAO.set(
+            "my-ext", "key", b"plaintext", user_fk=1, is_encrypted=True
+        )
+
+    mock_fernet_fn.return_value.encrypt.assert_called_once_with(b"plaintext")
+    added_entry = mock_db.session.add.call_args[0][0]
+    assert added_entry.value == b"ciphertext"
+    assert added_entry.is_encrypted is True
+
+
+# ── delete ────────────────────────────────────────────────────────────────────
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_delete_returns_true_when_entry_exists(
+    mock_db: MagicMock, app: Flask
+) -> None:
+    """delete() returns True and removes the row when the entry is found."""
+    entry = MagicMock()
+    mock_db.session.query.return_value.filter.return_value.first.return_value = entry
+
+    with app.app_context():
+        result = ExtensionStorageDAO.delete("my-ext", "key", user_fk=1)
+
+    assert result is True
+    mock_db.session.delete.assert_called_once_with(entry)
+    mock_db.session.flush.assert_called_once()
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_delete_returns_false_when_not_found(
+    mock_db: MagicMock, app: Flask
+) -> None:
+    """delete() returns False without touching the session when entry is absent."""
+    mock_db.session.query.return_value.filter.return_value.first.return_value = None
+
+    with app.app_context():
+        result = ExtensionStorageDAO.delete("my-ext", "key", user_fk=1)
+
+    assert result is False
+    mock_db.session.delete.assert_not_called()
+
+
+# ── scoping ───────────────────────────────────────────────────────────────────
+
+
+@patch("superset.extensions.storage.persistent_state_dao.db")
+def test_dao_user_and_shared_scopes_issue_independent_queries(
+    mock_db: MagicMock, app: Flask
+) -> None:
+    """User-scoped (user_fk=N) and shared-scoped (user_fk=None) lookups are separate."""
+    first_call = MagicMock(return_value=None)
+    mock_db.session.query.return_value.filter.return_value.first = first_call
+
+    with app.app_context():
+        ExtensionStorageDAO.get("my-ext", "key", user_fk=42)
+        ExtensionStorageDAO.get("my-ext", "key", user_fk=None)
+
+    # Each scope issues its own independent DB query
+    assert first_call.call_count == 2