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

betodealmeida pushed a commit to branch sl-oauth2
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 117e8b4cb6300c09988e45643bb77aab631fe374
Author: Beto Dealmeida <[email protected]>
AuthorDate: Mon Sep 28 18:37:22 2026 -0400

    WIP
---
 QUIVER_SEMANTIC_LAYER_OAUTH_PLAN.md                | 582 +++++++++++++++++++++
 docs/developer_docs/extensions/storage.md          |  50 +-
 .../src/superset_core/extensions/context.py        |   6 +
 .../superset_core/extensions/storage/__init__.py   |   2 +
 .../src/superset_core/extensions/storage/models.py |   9 +
 .../superset_core/extensions/storage/persistent.py |   2 +-
 .../superset_core/extensions/storage/secrets.py    |  21 +-
 .../src/superset_core/semantic_layers/layer.py     |  48 +-
 .../semanticLayers/SemanticLayerModal.test.tsx     |  62 ++-
 .../features/semanticLayers/SemanticLayerModal.tsx | 111 +++-
 superset/commands/semantic_layer/create.py         |   5 +-
 superset/commands/semantic_layer/update.py         |  13 +-
 superset/core/api/core_api_injection.py            |  49 +-
 superset/extensions/context.py                     |  21 +-
 superset/extensions/storage/__init__.py            |   1 +
 superset/extensions/storage/api.py                 |  17 +-
 superset/extensions/storage/persistent_dao.py      |  97 +++-
 superset/extensions/storage/persistent_model.py    |   9 +
 superset/extensions/storage/secrets.py             |  78 +++
 superset/extensions/storage/utils.py               |   5 +-
 ...00_4c8f2a1d9b73_add_extension_storage_access.py |  95 ++++
 superset/semantic_layers/api.py                    | 199 ++++++-
 superset/semantic_layers/masking.py                |   5 +-
 superset/semantic_layers/models.py                 |  10 +-
 superset/semantic_layers/registry.py               | 146 +++++-
 tests/unit_tests/extensions/api_context_test.py    |  52 ++
 tests/unit_tests/extensions/storage/test_api.py    |   5 +
 .../unit_tests/extensions/storage/test_context.py  |  12 +-
 .../extensions/storage/test_persistent_dao.py      |  90 +++-
 .../unit_tests/extensions/storage/test_secrets.py  | 112 ++++
 tests/unit_tests/semantic_layers/api_test.py       | 141 +++++
 .../unit_tests/semantic_layers/decorators_test.py  |   8 +-
 tests/unit_tests/semantic_layers/registry_test.py  |  69 +++
 33 files changed, 2051 insertions(+), 81 deletions(-)

diff --git a/QUIVER_SEMANTIC_LAYER_OAUTH_PLAN.md 
b/QUIVER_SEMANTIC_LAYER_OAUTH_PLAN.md
new file mode 100644
index 00000000000..4ff61ab1d3f
--- /dev/null
+++ b/QUIVER_SEMANTIC_LAYER_OAUTH_PLAN.md
@@ -0,0 +1,582 @@
+# Quiver Semantic Layer OAuth Implementation Plan
+
+## Objective
+
+Enable semantic layer extensions such as Quiver to store and use OAuth2 tokens
+without exposing those tokens to browser code and without requiring a semantic
+layer row to exist before authorization begins.
+
+The design uses `extension_storage` as the permanent token store. Tokens are
+written directly during the OAuth callback for every user, including the admin
+creating the semantic layer. It does not use a temporary KV escrow.
+
+## Target Architecture
+
+```text
+Semantic Layer configuration
+        |
+        +-- Quiver settings produce stable credential_key
+        |
+        +-- "Connect to Quiver"
+        |      +-- OAuth2 + PKCE
+        |             +-- callback
+        |                    +-- extension_storage
+        |                        extension = netflix.quiver
+        |                        user      = current user
+        |                        access    = backend
+        |                        key       = oauth2:v1:<credential hash>
+        |                        encrypted = true
+        |
+        +-- Create Semantic Layer
+               +-- uses the same credential_key
+```
+
+The admin creating the semantic layer follows the same OAuth path as every
+other user. The credential is associated with the extension, current user, and
+a stable Quiver credential identity rather than a semantic layer UUID.
+
+## 1. Add a Backend-Only Secrets Namespace
+
+Expose a dedicated Python-only API:
+
+```python
+ctx.storage.secrets.get(key)
+ctx.storage.secrets.set(key, value)
+ctx.storage.secrets.remove(key)
+```
+
+This is preferable to adding `backend_only=True` to the ordinary persistent
+accessor because it provides safer defaults:
+
+- It exists only in Python.
+- It always encrypts values.
+- It is always user-scoped.
+- It has no `.shared` accessor.
+- It does not support listing.
+- It cannot accidentally become available in the TypeScript SDK.
+- Ordinary `ctx.storage.persistent` behavior remains unchanged.
+
+### Storage Model
+
+Modify `superset/extensions/storage/persistent_model.py` and the corresponding
+core model declaration to add:
+
+```python
+access: str  # "frontend" or "backend"
+```
+
+Existing rows receive `access="frontend"`.
+
+The logical identity becomes:
+
+```text
+(extension_id, user_fk, resource_type, resource_uuid, access, key)
+```
+
+Including `access` in the lookup identity allows a browser-visible value and a
+backend secret to use the same key without allowing frontend `PUT`, `GET`, or
+`DELETE` requests to reveal whether the secret exists.
+
+Use an application-level enum backed by a portable string column rather than a
+database enum:
+
+```python
+class StorageAccess(str, Enum):
+    FRONTEND = "frontend"
+    BACKEND = "backend"
+```
+
+Update:
+
+- `superset/extensions/storage/persistent_model.py`
+- `superset-core/src/superset_core/extensions/storage/models.py`
+- `superset/extensions/storage/persistent_dao.py`
+- Extension storage lookup indexes and unique constraint
+- A new migration under `superset/migrations/versions/`
+
+The migration should:
+
+1. Add `access`, non-null, with a server default of `frontend`.
+2. Replace the scoped lookup index with one containing `access`.
+3. Replace the scoped unique constraint with one containing `access`.
+4. Preserve every existing row as frontend-visible.
+5. On downgrade, delete backend-only entries before removing the column;
+   converting them to frontend-visible entries would expose secrets.
+
+### Secrets API
+
+Add a new core interface, likely at:
+
+```text
+superset-core/src/superset_core/extensions/storage/secrets.py
+```
+
+Add `secrets` to:
+
+- `superset-core/src/superset_core/extensions/context.py`
+- `superset/extensions/context.py`
+- The storage injection in `superset/core/api/core_api_injection.py`
+
+The host implementation should call the DAO with:
+
+```python
+access=StorageAccess.BACKEND
+encrypt=True
+user_fk=current_user_id
+codec="json"
+```
+
+Do not expose backend access in:
+
+- `superset-frontend/packages/superset-core/src/storage/index.ts`
+- REST request parameters
+- REST request bodies
+
+### DAO Invariants
+
+DAO methods should always receive an access class, defaulting to `FRONTEND`
+for backward compatibility.
+
+The secrets accessor must enforce encryption internally. It must not accept an
+option that permits `encrypt=False`.
+
+Quota accounting should continue to include both frontend and backend entries.
+
+## 2. Make the Browser API Blind to Backend Entries
+
+Update `superset/extensions/storage/api.py` so every persistent REST operation
+is hard-coded to `access=FRONTEND`.
+
+Required behavior:
+
+- `GET`: a backend-only entry behaves as if it does not exist.
+- `list`: backend entries are excluded before count, pagination, and payload
+  size calculation.
+- `PUT`: writes only to the frontend namespace.
+- `DELETE`: removes only from the frontend namespace.
+- No response reveals the backend key, codec, size, encryption flag, or
+  existence.
+- Supplying `access`, `backend_only`, or similar fields must not change the
+  namespace.
+
+Because access is part of the logical key, frontend `PUT` does not need to
+reject a colliding backend key. It creates or updates an independent frontend
+entry.
+
+### Storage Tests
+
+Extend:
+
+- `tests/unit_tests/extensions/storage/test_api.py`
+- `tests/unit_tests/extensions/storage/test_persistent.py`
+- `tests/unit_tests/extensions/storage/test_persistent_dao.py`
+- `tests/unit_tests/extensions/storage/test_context.py`
+
+Cover:
+
+- Secret round-trip for the current user.
+- Separation between two users.
+- Separation between two extensions.
+- Forced encryption.
+- Absence of a shared secret accessor.
+- Browser `GET` cannot read a secret.
+- Browser `list` omits secrets and reports the filtered count.
+- Browser `DELETE` does not remove a secret.
+- Browser `PUT` cannot overwrite a secret.
+- Frontend and backend entries can coexist at the same key.
+- Unauthenticated use fails closed.
+- Existing and migrated rows remain frontend-visible.
+
+## 3. Restore Extension Context During Semantic Layer Execution
+
+Storage depends on ambient extension context, but semantic layer providers are
+registered during extension loading and invoked later, outside that loading
+context.
+
+The registration process must retain the owning extension manifest.
+
+Update:
+
+- `superset/semantic_layers/registry.py`
+- Semantic layer decorator injection in
+  `superset/core/api/core_api_injection.py`
+- `superset/semantic_layers/models.py`
+- Class-level registry consumers in `superset/semantic_layers/api.py`
+- Create and update commands under `superset/commands/semantic_layer/`
+
+A registry entry should retain:
+
+```python
+@dataclass(frozen=True)
+class RegisteredSemanticLayer:
+    implementation: type[SemanticLayer]
+    extension_manifest: Manifest | None
+```
+
+Host semantic layers have `extension_manifest=None`.
+
+### Contextual Adapters
+
+Avoid relying on scattered `with extension_context(...)` calls. Introduce
+explicit adapters for:
+
+- Semantic layer class operations
+- Semantic layer instances
+- Semantic view instances returned by a layer
+
+Each adapter enters the saved extension context before invoking the provider.
+
+Explicit adapters implementing the semantic layer protocols are preferable to
+a generic `__getattr__` proxy. When a new protocol method is added, typing and
+tests will then show that its context forwarding is missing.
+
+The wrapper must cover at least:
+
+- `from_configuration`
+- Configuration schema generation
+- Runtime schema generation
+- Semantic view discovery
+- `get_semantic_view`
+- Metrics and dimensions
+- Compatible metrics and dimensions
+- Values lookup
+- Query and table generation
+- Row count
+- Feature or property access that can call extension code
+
+Tests belong in:
+
+- `tests/unit_tests/semantic_layers/decorators_test.py`
+- `tests/unit_tests/semantic_layers/models_test.py`
+- A focused new contextual execution test module
+
+The test extension should call `get_context()` and `ctx.storage.secrets` from
+both a layer and a returned view.
+
+## 4. Carry the Initiating User Into Background Execution
+
+Extension identity alone is insufficient because personal tokens require the
+initiating user.
+
+The execution context should support an explicit principal ID:
+
+```python
+extension_context(manifest, user_id=user_id)
+```
+
+`get_current_user_id()` should:
+
+1. Prefer the explicit execution principal.
+2. Fall back to the authenticated Flask user during request execution.
+3. Fail if neither exists.
+
+For async chart and query paths, propagate the initiating user already present
+in query or task metadata into the semantic layer execution context. Never
+fall back to:
+
+- The semantic layer creator
+- The admin who configured it
+- The Celery worker or service account
+
+This is required by Superset's async security model: a worker is a continuation
+of the initiating principal.
+
+Embedded guests and anonymous or Public users have no personal Quiver token
+and should fail closed unless Quiver later defines a separate service 
credential
+design.
+
+## 5. Add Generic Semantic Layer Connection Actions
+
+The semantic layer modal needs a way to initiate OAuth before a `SemanticLayer`
+row exists. This should be a generic contribution rather than a Quiver-specific
+button in Superset.
+
+Add a typed semantic layer action contract to `superset-core`, for example:
+
+```python
+@dataclass(frozen=True)
+class SemanticLayerAction:
+    id: str
+    label: str
+    status: Literal["required", "connected", "expired", "error"]
+    required: bool
+    message: str | None = None
+```
+
+Provider hooks could be:
+
+```python
+get_configuration_actions(configuration)
+execute_configuration_action(action_id, configuration, return_url)
+```
+
+The default implementation returns no actions, preserving existing providers.
+
+### API Shape
+
+Extend the existing configuration schema response:
+
+```json
+{
+  "result": { "...": "JSON schema" },
+  "actions": [
+    {
+      "id": "oauth2",
+      "label": "Connect to Quiver",
+      "status": "required",
+      "required": true
+    }
+  ]
+}
+```
+
+Add protected endpoints for executing actions:
+
+```text
+POST /api/v1/semantic_layer/type/<type>/actions/<action_id>
+POST /api/v1/semantic_layer/<uuid>/actions/<action_id>
+GET  /api/v1/semantic_layer/<uuid>/actions
+```
+
+The type endpoint supports pre-creation setup and receives partial
+configuration. It should require semantic layer write permission.
+
+The UUID endpoint supports existing users connecting their own account. It
+should require read access to the semantic layer or data source, rather than
+edit permission, because connecting a personal token does not modify the
+semantic layer.
+
+The action returns an OAuth authorization URL or a same-origin redirect
+response, never a token.
+
+Class hooks and action execution must run through the extension context
+adapter.
+
+## 6. Update the Semantic Layer Frontend
+
+Update
+`superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx` to:
+
+1. Read `actions` from the schema response.
+2. Render a standard `@superset-ui/core/components` button and status.
+3. Send the partial configuration when starting the action.
+4. Open the OAuth flow in a popup or redirect.
+5. Poll or refetch schema and action status after completion.
+6. Disable creation while a required action is not connected.
+7. Never receive or store access or refresh tokens.
+
+Update
+`superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx`
+for:
+
+- Providers without actions.
+- A required disconnected action.
+- Successful connection and schema refresh.
+- Popup cancellation.
+- OAuth failure.
+- A required action blocking creation.
+- Edit-mode reconnection.
+
+For existing users, add a reusable Connect action surfaced when the semantic
+layer reports that authentication is required. A typed
+`SemanticLayerAuthenticationRequiredError` should carry only:
+
+- Semantic layer UUID
+- Action ID
+- Human-readable message
+
+The frontend can then call the UUID action endpoint. It must not receive
+credential keys or token metadata beyond connected or expired status.
+
+## 7. Define the Quiver Credential Identity
+
+Tokens must be addressable before semantic layer creation and reusable by
+multiple Quiver semantic layers.
+
+Build a canonical non-secret identity from fields such as:
+
+```json
+{
+  "quiver_url": "...",
+  "issuer": "...",
+  "client_id": "...",
+  "audience": "...",
+  "scopes": ["..."]
+}
+```
+
+Normalize URLs and sort scopes, then hash the canonical JSON:
+
+```text
+oauth2:v1:<base64url-sha256>
+```
+
+Do not include:
+
+- Client secret
+- Access token
+- Refresh token
+- Semantic layer UUID
+- Semantic layer name
+
+The resulting record is:
+
+```text
+extension_id = netflix.quiver
+user_fk      = authenticated user
+access       = backend
+key          = oauth2:v1:<credential hash>
+value        = {
+  access_token,
+  refresh_token,
+  token_type,
+  expires_at,
+  granted_scopes
+}
+is_encrypted = true
+```
+
+Deleting one semantic layer must not delete this entry because several layers
+may share it. Provide an explicit Disconnect Quiver action instead.
+
+Changing identity fields produces a new credential key. Old credentials can
+be removed through Disconnect or eventual extension cleanup.
+
+## 8. Implement OAuth in the Quiver Extension
+
+Superset provides storage, execution context, and action UI. The Quiver
+extension remains responsible for OAuth protocol behavior.
+
+### Start
+
+The start handler should:
+
+- Require an authenticated user.
+- Derive the credential key from submitted non-secret configuration.
+- Generate state, nonce, and a PKCE verifier and challenge.
+- Bind signed state to the user ID, credential key, return location, nonce,
+  and expiry.
+- Store the PKCE verifier in a short-lived backend cache inaccessible through
+  extension browser storage.
+- Redirect to the configured issuer.
+
+The PKCE verifier is temporary protocol state, not token escrow.
+
+### Callback
+
+The callback should:
+
+- Validate the state signature and age.
+- Require the same authenticated user recorded in state.
+- Consume the nonce and verifier exactly once.
+- Exchange the authorization code server-side.
+- Write the resulting token directly through `ctx.storage.secrets`.
+- Return a minimal success page or redirect.
+- Never include tokens in URLs, HTML, `postMessage`, logs, or frontend
+  responses.
+
+### Refresh
+
+Before calling Quiver:
+
+1. Load the user's token.
+2. Refresh if it is near expiry.
+3. Acquire a distributed lock scoped to extension, user, and credential key.
+4. Reread after acquiring the lock.
+5. Refresh only if it is still necessary.
+6. Persist the new access token and any rotated refresh token.
+
+On `invalid_grant`, remove the stored secret and raise the typed authentication
+required error.
+
+## 9. Update Documentation
+
+Update `docs/developer_docs/extensions/storage.md` to make the distinction
+explicit:
+
+- `encrypt=True` means encryption at rest.
+- Ordinary persistent values remain readable by the extension frontend.
+- Tokens and backend credentials belong in `ctx.storage.secrets`.
+- Secrets are encrypted, user-scoped, backend-only, non-listable, and
+  unavailable in TypeScript.
+- `.shared` is intentionally unavailable for secrets.
+- Secret values remain subject to extension storage quotas.
+- User deletion cascades to personal secrets.
+
+Also add semantic layer extension documentation covering:
+
+- Runtime extension context
+- Configuration actions
+- Pre-creation OAuth
+- Personal token behavior in background queries
+- Disconnect and reconnect behavior
+
+The existing `api_token` example under ordinary persistent storage should be
+changed because it suggests a browser-readable location for bearer
+credentials.
+
+## 10. Suggested Pull Request Sequence
+
+### PR 1: Backend-Only Extension Secrets
+
+- Migration and model changes
+- DAO access namespace
+- `ctx.storage.secrets`
+- REST isolation
+- Storage tests
+- Storage documentation
+
+This PR is independently useful to all backend extensions.
+
+### PR 2: Semantic Layer Runtime Context
+
+- Registered provider metadata
+- Contextual layer and view adapters
+- Explicit user principal propagation
+- Synchronous and asynchronous execution tests
+
+This makes extension storage usable reliably from semantic layer providers.
+
+### PR 3: Semantic Layer Connection Actions
+
+- Core action types and hooks
+- Protected action endpoints
+- Modal integration
+- Authentication-required error contract
+- Frontend and API tests
+- Developer documentation
+
+### PR 4: Quiver Extension
+
+- Credential key derivation
+- OAuth start and callback
+- Token storage and refresh locking
+- Disconnect and reconnect
+- Quiver-specific tests
+
+## Explicit Non-Goals
+
+- Do not refactor `database_user_oauth2_tokens`.
+- Do not migrate existing database OAuth flows.
+- Do not use `localStorage`, `sessionStorage`, cookies, or frontend Redux state
+  for tokens.
+- Do not use KV escrow for the initial admin.
+- Do not key tokens to semantic layer UUIDs.
+- Do not expose a general browser flag for creating backend-only entries.
+- Do not introduce shared or service-account credentials as part of the
+  personal OAuth implementation.
+
+## Completion Criteria
+
+The work is complete when:
+
+- An admin can authorize Quiver before creating the semantic layer.
+- No semantic layer row is required to store that token.
+- Another user sees a Connect action and stores a separate personal token.
+- Synchronous and asynchronous queries use the initiating user's token.
+- Multiple semantic layers can reuse the same user credential.
+- Browser storage APIs cannot read, enumerate, overwrite, or delete the token.
+- Refresh token rotation is persisted safely.
+- Revoked credentials produce a reconnect action.
+- Existing Tier 3 storage behavior remains backward compatible.
+- Targeted tests and staged-file `pre-commit run` pass.
diff --git a/docs/developer_docs/extensions/storage.md 
b/docs/developer_docs/extensions/storage.md
index 93c13b07ff0..d1e633f621f 100644
--- a/docs/developer_docs/extensions/storage.md
+++ b/docs/developer_docs/extensions/storage.md
@@ -438,8 +438,8 @@ import { extensions } from '@apache-superset/core';
 
 const ctx = extensions.getContext();
 
-await ctx.storage.persistent.set('api_token', 'sk-...', { encrypt: true });
-const token = await ctx.storage.persistent.get('api_token');
+await ctx.storage.persistent.set('private_note', 'draft', { encrypt: true });
+const note = await ctx.storage.persistent.get('private_note');
 ```
 
 ```python
@@ -448,10 +448,52 @@ from superset_core.extensions.storage.persistent import 
PersistentSetOptions
 
 ctx = get_context()
 
-ctx.storage.persistent.set('api_token', 'sk-...', 
PersistentSetOptions(encrypt=True))
-token = ctx.storage.persistent.get('api_token')
+ctx.storage.persistent.set(
+    'private_note',
+    'draft',
+    PersistentSetOptions(encrypt=True),
+)
+note = ctx.storage.persistent.get('private_note')
 ```
 
+Encryption protects a value at rest; it does not make an ordinary persistent
+value backend-only. The persistent API is intentionally available to frontend
+extensions, so the authenticated user's browser can read values written with
+`encrypt=True`. Do not use `persistent` for OAuth tokens, private keys, or
+other credentials that must stay on the server.
+
+### Backend-only secrets
+
+Backend extensions can store user-scoped credentials through the Python-only
+secrets accessor:
+
+```python
+from superset_core.extensions.context import get_context
+
+ctx = get_context()
+ctx.storage.secrets.set(
+    "oauth2:quiver",
+    {
+        "access_token": access_token,
+        "refresh_token": refresh_token,
+        "expires_at": expires_at,
+    },
+)
+token = ctx.storage.secrets.get("oauth2:quiver")
+ctx.storage.secrets.remove("oauth2:quiver")
+```
+
+Secrets are always encrypted at rest and scoped to the current authenticated
+user and extension. They cannot be read, listed, overwritten, or deleted
+through the extension storage REST API and are not exposed by the TypeScript
+SDK. The accessor intentionally has no `list()` or `.shared` operation. Use
+deployment configuration for extension-wide client secrets or service
+credentials.
+
+Secret operations require an authenticated principal. Background work must
+execute with the identity of the user who initiated it; the accessor fails
+closed when no user identity is available.
+
 Encryption reuses Superset's existing `EncryptedType` (from 
`sqlalchemy-utils`) rather than a separate mechanism — the same infrastructure 
used for database connection credentials. The key is not configured directly: 
user-scoped values are encrypted with a key derived per-user via HMAC-SHA256 
from the deployment's `SECRET_KEY`, so ciphertext for one user cannot be 
decrypted as another's; shared/global values (written via `.shared`) use 
`SECRET_KEY` directly. The encryption engine (AES-CB [...]
 
 ### Quotas
diff --git a/superset-core/src/superset_core/extensions/context.py 
b/superset-core/src/superset_core/extensions/context.py
index 63c8ea35e78..a9a3a45a488 100644
--- a/superset-core/src/superset_core/extensions/context.py
+++ b/superset-core/src/superset_core/extensions/context.py
@@ -46,6 +46,7 @@ from typing import Protocol, TYPE_CHECKING
 if TYPE_CHECKING:
     from superset_core.extensions.storage.ephemeral import 
EphemeralStateAccessor
     from superset_core.extensions.storage.persistent import 
PersistentStateAccessor
+    from superset_core.extensions.storage.secrets import SecretsState
     from superset_core.extensions.types import BaseExtension
 
 
@@ -62,6 +63,11 @@ class ExtensionStorage(Protocol):
         """Database-backed persistent storage."""
         ...
 
+    @property
+    def secrets(self) -> "SecretsState":
+        """Backend-only encrypted storage for user-scoped secrets."""
+        ...
+
 
 class ExtensionContext(Protocol):
     """
diff --git a/superset-core/src/superset_core/extensions/storage/__init__.py 
b/superset-core/src/superset_core/extensions/storage/__init__.py
index 095b1b9c9cb..e0c4359e5ff 100644
--- a/superset-core/src/superset_core/extensions/storage/__init__.py
+++ b/superset-core/src/superset_core/extensions/storage/__init__.py
@@ -35,6 +35,7 @@ Tier 3 - Persistent State (Database):
     - persistent: Durable KV storage backed by database table
     - Survives server restarts, supports encryption and resource linking
     - Use for user preferences, extension config, per-resource settings
+    - secrets: Python-only, encrypted user credentials unavailable to browsers
 
 All tiers follow the same API pattern:
     - User-scoped by default (private to current user)
@@ -76,4 +77,5 @@ from superset_core.extensions.storage import (
     ephemeral,  # noqa: F401
     models,  # noqa: F401
     persistent,  # noqa: F401
+    secrets,  # noqa: F401
 )
diff --git a/superset-core/src/superset_core/extensions/storage/models.py 
b/superset-core/src/superset_core/extensions/storage/models.py
index 847052ed021..64384edba0e 100644
--- a/superset-core/src/superset_core/extensions/storage/models.py
+++ b/superset-core/src/superset_core/extensions/storage/models.py
@@ -30,6 +30,7 @@ Usage:
 
 from __future__ import annotations
 
+from enum import Enum
 from typing import TYPE_CHECKING
 from uuid import UUID
 
@@ -39,6 +40,13 @@ if TYPE_CHECKING:
     from datetime import datetime
 
 
+class StorageAccess(str, Enum):
+    """Controls whether a persistent storage entry is browser-accessible."""
+
+    FRONTEND = "frontend"
+    BACKEND = "backend"
+
+
 class ExtensionStorageEntry(CoreModel):
     """
     Abstract model interface for a single Tier 3 persistent storage row.
@@ -66,6 +74,7 @@ class ExtensionStorageEntry(CoreModel):
     value_size: int
     codec: str
     is_encrypted: bool
+    access: str
 
     # Audit fields
     created_on: "datetime | None"
diff --git a/superset-core/src/superset_core/extensions/storage/persistent.py 
b/superset-core/src/superset_core/extensions/storage/persistent.py
index fa2b7e5ad31..8f3f9ffb171 100644
--- a/superset-core/src/superset_core/extensions/storage/persistent.py
+++ b/superset-core/src/superset_core/extensions/storage/persistent.py
@@ -46,7 +46,7 @@ Usage (via extension context - preferred):
 
     # Encrypted at rest
     ctx.storage.persistent.set(
-        'api_token', 'sk-...', PersistentSetOptions(encrypt=True)
+        'private_note', 'draft', PersistentSetOptions(encrypt=True)
     )
 
     # Listing entries (page and page_size are required)
diff --git a/superset/semantic_layers/registry.py 
b/superset-core/src/superset_core/extensions/storage/secrets.py
similarity index 52%
copy from superset/semantic_layers/registry.py
copy to superset-core/src/superset_core/extensions/storage/secrets.py
index 076d1aa87de..c4053775b68 100644
--- a/superset/semantic_layers/registry.py
+++ b/superset-core/src/superset_core/extensions/storage/secrets.py
@@ -15,10 +15,27 @@
 # specific language governing permissions and limitations
 # under the License.
 
+"""Backend-only, encrypted storage for user-scoped extension secrets."""
+
 from __future__ import annotations
 
 from typing import Any
 
-from superset_core.semantic_layers.layer import SemanticLayer
 
-registry: dict[str, type[SemanticLayer[Any, Any]]] = {}
+class SecretsState:
+    """User-scoped persistent secrets unavailable to extension frontend 
code."""
+
+    @staticmethod
+    def get(key: str) -> Any:
+        """Return the current user's secret, or ``None`` when absent."""
+        raise NotImplementedError("Class will be replaced during 
initialization")
+
+    @staticmethod
+    def set(key: str, value: Any) -> None:
+        """Store an encrypted secret for the current user."""
+        raise NotImplementedError("Class will be replaced during 
initialization")
+
+    @staticmethod
+    def remove(key: str) -> None:
+        """Remove the current user's secret."""
+        raise NotImplementedError("Class will be replaced during 
initialization")
diff --git a/superset-core/src/superset_core/semantic_layers/layer.py 
b/superset-core/src/superset_core/semantic_layers/layer.py
index ba81d90b1f6..4a293eed705 100644
--- a/superset-core/src/superset_core/semantic_layers/layer.py
+++ b/superset-core/src/superset_core/semantic_layers/layer.py
@@ -18,7 +18,8 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import Any, Generic, TypeVar
+from dataclasses import dataclass
+from typing import Any, Generic, Literal, TypeVar
 
 from pydantic import BaseModel
 
@@ -28,6 +29,33 @@ ConfigT = TypeVar("ConfigT", bound=BaseModel)
 SemanticViewT = TypeVar("SemanticViewT", bound="SemanticView")
 
 
+@dataclass(frozen=True)
+class SemanticLayerAction:
+    """A user action required to configure or authenticate a provider."""
+
+    id: str
+    label: str
+    status: Literal["required", "connected", "expired", "error"]
+    required: bool = False
+    message: str | None = None
+
+
+@dataclass(frozen=True)
+class SemanticLayerActionResult:
+    """Result of starting a semantic layer configuration action."""
+
+    redirect_url: str
+
+
+class SemanticLayerAuthenticationRequiredError(Exception):
+    """Raised when a provider needs a personal authentication action."""
+
+    def __init__(self, action_id: str, message: str = "Authentication 
required"):
+        super().__init__(message)
+        self.action_id = action_id
+        self.message = message
+
+
 class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
     """
     Abstract base class for semantic layers.
@@ -35,6 +63,24 @@ class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
 
     configuration_class: type[BaseModel]
 
+    @classmethod
+    def get_configuration_actions(
+        cls,
+        configuration: ConfigT | None = None,
+    ) -> list[SemanticLayerAction]:
+        """Return actions associated with the current user's configuration."""
+        return []
+
+    @classmethod
+    def execute_configuration_action(
+        cls,
+        action_id: str,
+        configuration: ConfigT | None,
+        return_url: str | None = None,
+    ) -> SemanticLayerActionResult:
+        """Start an action and return the URL to which the user should 
navigate."""
+        raise ValueError(f"Unknown semantic layer action: {action_id}")
+
     @classmethod
     @abstractmethod
     def from_configuration(
diff --git 
a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx 
b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
index 248e1d7728c..489113015f6 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
@@ -17,7 +17,13 @@
  * under the License.
  */
 import { SupersetClient } from '@superset-ui/core';
-import { act, render, waitFor } from 'spec/helpers/testing-library';
+import {
+  act,
+  fireEvent,
+  render,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
 
 import SemanticLayerModal from './SemanticLayerModal';
 
@@ -225,3 +231,57 @@ test('cancels pending schema refresh when dependencies 
become unsatisfied', asyn
   // No additional POST should have fired; the cancelled timer must not land.
   expect(mockedPost).toHaveBeenCalledTimes(1);
 });
+
+test('starts a required semantic layer configuration action', async () => {
+  mockedPost
+    .mockResolvedValueOnce({
+      json: {
+        result: schemaWithExternalDeps,
+        actions: [
+          {
+            id: 'oauth2',
+            label: 'Connect to Quiver',
+            status: 'required',
+            required: true,
+          },
+        ],
+      },
+    })
+    .mockResolvedValueOnce({
+      json: {
+        result: { redirect_url: 'https://auth.example.test/authorize' },
+      },
+    });
+  const popup = {
+    close: jest.fn(),
+    location: { href: '' },
+  } as unknown as Window;
+  const open = jest.spyOn(window, 'open').mockReturnValue(popup);
+
+  render(<SemanticLayerModal {...props} />);
+
+  const connect = await screen.findByRole('button', {
+    name: 'Connect to Quiver',
+  });
+  expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+  fireEvent.click(connect);
+
+  await waitFor(() => {
+    expect(mockedPost).toHaveBeenNthCalledWith(2, {
+      endpoint: '/api/v1/semantic_layer/type/snowflake/actions/oauth2',
+      jsonPayload: {
+        configuration: expect.any(Object),
+        return_url: window.location.href,
+        semantic_layer_uuid: props.semanticLayerUuid,
+      },
+    });
+    expect(open).toHaveBeenCalledWith(
+      '',
+      'semantic-layer-action-oauth2',
+      'popup,width=720,height=800',
+    );
+    expect(popup.location.href).toBe('https://auth.example.test/authorize');
+  });
+
+  open.mockRestore();
+});
diff --git 
a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx 
b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
index c875e835f58..e43e16ffb26 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -55,6 +55,14 @@ interface SemanticLayerType {
   description: string;
 }
 
+interface SemanticLayerAction {
+  id: string;
+  label: string;
+  status: 'required' | 'connected' | 'expired' | 'error';
+  required: boolean;
+  message?: string | null;
+}
+
 interface SemanticLayerModalProps {
   show: boolean;
   onHide: () => void;
@@ -77,6 +85,10 @@ export default function SemanticLayerModal({
   const [types, setTypes] = useState<SemanticLayerType[]>([]);
   const [loading, setLoading] = useState(false);
   const [configSchema, setConfigSchema] = useState<JsonSchema | null>(null);
+  const [configurationActions, setConfigurationActions] = useState<
+    SemanticLayerAction[]
+  >([]);
+  const [runningActionId, setRunningActionId] = useState<string | null>(null);
   const [uiSchema, setUiSchema] = useState<UISchemaElement | undefined>(
     undefined,
   );
@@ -88,6 +100,7 @@ export default function SemanticLayerModal({
     useState<ValidationMode>('ValidateAndHide');
   const errorsRef = useRef<ErrorObject[]>([]);
   const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const oauthFocusListenerRef = useRef<(() => void) | null>(null);
   const lastDepSnapshotRef = useRef<string>('');
   const dynamicDepsRef = useRef<Record<string, string[]>>({});
   // Tracks the most recent value we auto-populated into the Name field so we
@@ -131,6 +144,7 @@ export default function SemanticLayerModal({
           jsonPayload: { type, configuration },
         });
         applySchema(json.result);
+        setConfigurationActions(json.actions ?? []);
         if (json.warning) {
           addDangerToast(String(json.warning));
         }
@@ -177,6 +191,7 @@ export default function SemanticLayerModal({
           jsonPayload: { type: layer.type, configuration: layer.configuration 
},
         });
         applySchema(schemaJson.result);
+        setConfigurationActions(schemaJson.actions ?? []);
         setStep('config');
       } catch (error) {
         const clientError = await getClientErrorObject(error);
@@ -200,11 +215,17 @@ export default function SemanticLayerModal({
         fetchTypes();
       }
     } else {
+      if (oauthFocusListenerRef.current) {
+        window.removeEventListener('focus', oauthFocusListenerRef.current);
+        oauthFocusListenerRef.current = null;
+      }
       setStep('type');
       setName('');
       setSelectedType(null);
       setTypes([]);
       setConfigSchema(null);
+      setConfigurationActions([]);
+      setRunningActionId(null);
       setUiSchema(undefined);
       setFormData({});
       setHasErrors(true);
@@ -218,6 +239,15 @@ export default function SemanticLayerModal({
     }
   }, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
 
+  useEffect(
+    () => () => {
+      if (oauthFocusListenerRef.current) {
+        window.removeEventListener('focus', oauthFocusListenerRef.current);
+      }
+    },
+    [],
+  );
+
   const handleStepAdvance = () => {
     if (selectedType) {
       // Pre-fill the Name with the type's display name so a user who just
@@ -235,6 +265,7 @@ export default function SemanticLayerModal({
   const handleBack = () => {
     setStep('type');
     setConfigSchema(null);
+    setConfigurationActions([]);
     setUiSchema(undefined);
     setFormData({});
     setValidationMode('ValidateAndHide');
@@ -274,6 +305,62 @@ export default function SemanticLayerModal({
     }
   };
 
+  const handleConfigurationAction = async (action: SemanticLayerAction) => {
+    if (!selectedType) return;
+    const popup = window.open(
+      '',
+      `semantic-layer-action-${action.id}`,
+      'popup,width=720,height=800',
+    );
+    if (!popup) {
+      addDangerToast(t('Allow popups to connect this semantic layer'));
+      return;
+    }
+
+    setRunningActionId(action.id);
+    try {
+      const { json } = await SupersetClient.post({
+        endpoint: `/api/v1/semantic_layer/type/${encodeURIComponent(
+          selectedType,
+        )}/actions/${encodeURIComponent(action.id)}`,
+        jsonPayload: {
+          configuration: formData,
+          return_url: window.location.href,
+          ...(semanticLayerUuid
+            ? { semantic_layer_uuid: semanticLayerUuid }
+            : {}),
+        },
+      });
+      const redirectUrl = json.result?.redirect_url;
+      if (typeof redirectUrl !== 'string') {
+        throw new Error(t('The connection action did not return a URL'));
+      }
+      popup.location.href = redirectUrl;
+
+      const refreshAfterOAuth = () => {
+        window.removeEventListener('focus', refreshAfterOAuth);
+        oauthFocusListenerRef.current = null;
+        fetchConfigSchema(selectedType, formData);
+      };
+      if (oauthFocusListenerRef.current) {
+        window.removeEventListener('focus', oauthFocusListenerRef.current);
+      }
+      oauthFocusListenerRef.current = refreshAfterOAuth;
+      window.addEventListener('focus', refreshAfterOAuth);
+    } catch (error) {
+      popup.close();
+      const clientError = await getClientErrorObject(error);
+      addDangerToast(
+        clientError.error ||
+          (error instanceof Error
+            ? error.message
+            : t('An error occurred while starting the connection')),
+      );
+    } finally {
+      setRunningActionId(null);
+    }
+  };
+
   // Edit mode skips the type-picker step. Gating on this prevents the brief
   // flash of the Create modal's first step while the existing layer is being
   // fetched.
@@ -378,6 +465,9 @@ export default function SemanticLayerModal({
     : isTypeStep
       ? t('New Semantic Layer')
       : t('Configure %s', selectedTypeName);
+  const requiredActionPending = configurationActions.some(
+    action => action.required && action.status !== 'connected',
+  );
 
   return (
     <StandardModal
@@ -388,7 +478,9 @@ export default function SemanticLayerModal({
       icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
       width={isTypeStep ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
       saveDisabled={
-        isTypeStep ? !selectedType : saving || !name.trim() || hasErrors
+        isTypeStep
+          ? !selectedType
+          : saving || !name.trim() || hasErrors || requiredActionPending
       }
       saveText={isTypeStep ? undefined : isEditMode ? t('Save') : t('Create')}
       saveLoading={saving}
@@ -427,6 +519,23 @@ export default function SemanticLayerModal({
                 placeholder={t('Name of the semantic layer')}
               />
             </ModalFormField>
+            {configurationActions.map(action => (
+              <ModalFormField key={action.id} label={action.label}>
+                <Button
+                  buttonStyle="secondary"
+                  disabled={
+                    action.status === 'connected' || runningActionId !== null
+                  }
+                  loading={runningActionId === action.id}
+                  onClick={() => handleConfigurationAction(action)}
+                >
+                  {action.status === 'connected'
+                    ? t('Connected')
+                    : action.label}
+                </Button>
+                {action.message && <div>{action.message}</div>}
+              </ModalFormField>
+            ))}
             {configSchema && (
               // Wrap in a form with autocomplete="off" so browsers do not
               // autofill credential fields (service token, account, etc.).
diff --git a/superset/commands/semantic_layer/create.py 
b/superset/commands/semantic_layer/create.py
index b03d55ec01e..df1be98dc6a 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -35,7 +35,7 @@ from superset.commands.semantic_layer.utils import 
validate_configuration
 from superset.commands.utils import current_user_can_modify_object
 from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.exceptions import SupersetSecurityException
-from superset.semantic_layers.registry import registry
+from superset.semantic_layers.registry import registry, semantic_layer_context
 from superset.utils import json
 from superset.utils.decorators import on_error, transaction
 
@@ -72,7 +72,8 @@ class CreateSemanticLayerCommand(BaseCommand):
 
         # Validate configuration against the plugin
         cls = registry[sl_type]
-        validate_configuration(cls, self._properties["configuration"])
+        with semantic_layer_context(sl_type):
+            validate_configuration(cls, self._properties["configuration"])
 
 
 class CreateSemanticViewCommand(BaseCommand):
diff --git a/superset/commands/semantic_layer/update.py 
b/superset/commands/semantic_layer/update.py
index 6e42bc3ca52..371ec90348c 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -44,7 +44,7 @@ from superset.semantic_layers.masking import (
     unmask_configuration,
 )
 from superset.semantic_layers.models import SemanticLayer, SemanticView
-from superset.semantic_layers.registry import registry
+from superset.semantic_layers.registry import registry, semantic_layer_context
 from superset.utils import json
 from superset.utils.decorators import on_error, transaction
 
@@ -55,7 +55,7 @@ logger = logging.getLogger(__name__)
 _MISSING = object()
 
 
-def _unmask_configuration(
+def unmask_stored_configuration(
     existing_raw_configuration: str | None,
     new_configuration: dict[str, Any],
     layer_type: str,
@@ -135,6 +135,10 @@ def _unmask_configuration(
         raise SemanticLayerInvalidError(str(ex)) from None
 
 
+# Kept while callers transition to the descriptive shared name above.
+_unmask_configuration = unmask_stored_configuration
+
+
 class UpdateSemanticViewCommand(BaseCommand):
     def __init__(self, model_id: int, data: dict[str, Any]):
         self._model_id = model_id
@@ -218,7 +222,7 @@ class UpdateSemanticLayerCommand(BaseCommand):
             raise SemanticLayerInvalidError(f"Name already exists: {name}")
 
         if isinstance(self._properties.get("configuration"), dict):
-            self._properties["configuration"] = _unmask_configuration(
+            self._properties["configuration"] = unmask_stored_configuration(
                 self._model.configuration,
                 self._properties["configuration"],
                 self._model.type,
@@ -229,4 +233,5 @@ class UpdateSemanticLayerCommand(BaseCommand):
             sl_type: str = self._model.type
             if sl_type not in registry:
                 raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
-            validate_configuration(registry[sl_type], configuration)
+            with semantic_layer_context(sl_type):
+                validate_configuration(registry[sl_type], configuration)
diff --git a/superset/core/api/core_api_injection.py 
b/superset/core/api/core_api_injection.py
index f2d48f31853..c42c68290d1 100644
--- a/superset/core/api/core_api_injection.py
+++ b/superset/core/api/core_api_injection.py
@@ -23,17 +23,52 @@ into the abstract superset-core API modules. This allows 
the core API
 to be used with direct imports while maintaining loose coupling.
 """
 
+from functools import wraps
 from typing import Any, Callable, TYPE_CHECKING, TypeVar
 
 from sqlalchemy.orm import scoped_session
 
-from superset.extensions.context import get_current_extension_context
+from superset.extensions.context import (
+    extension_context,
+    get_current_extension_context,
+)
+from superset.utils.core import get_user_id
 
 if TYPE_CHECKING:
     from superset_core.common.models import Database
+    from superset_core.extensions.types import Manifest
     from superset_core.rest_api.api import RestApi
 
 
+def _contextualize_extension_api(
+    api_class: type["RestApi"],
+    manifest: "Manifest",
+) -> None:
+    """Execute exposed extension API methods with extension and user 
context."""
+    for attribute_name, attribute in vars(api_class).items():
+        if not callable(attribute) or not getattr(attribute, "_urls", None):
+            continue
+        setattr(
+            api_class,
+            attribute_name,
+            _contextualize_extension_api_method(attribute, manifest),
+        )
+
+
+def _contextualize_extension_api_method(
+    method: Callable[..., Any],
+    manifest: "Manifest",
+) -> Callable[..., Any]:
+    """Wrap one API method in its extension's execution context."""
+
+    @wraps(method)
+    def contextualized(*args: Any, **kwargs: Any) -> Any:
+        with extension_context(manifest, user_id=get_user_id()):
+            return method(*args, **kwargs)
+
+    return contextualized
+
+
 def inject_dao_implementations() -> None:
     """
     Replace abstract DAO classes in superset_core common/queries/tasks daos 
with
@@ -196,6 +231,7 @@ def inject_rest_api_implementations() -> None:
                     f"extensions.{context.extension.publisher}."
                     f"{context.extension.name}.{id}"
                 )
+                _contextualize_extension_api(api_class, context.extension)
 
             else:
                 # HOST CONTEXT
@@ -254,7 +290,7 @@ def inject_semantic_layer_implementations() -> None:
     import superset_core.semantic_layers.decorators as core_sl_module
 
     import superset.extensions.context as context_module
-    from superset.semantic_layers.registry import registry
+    from superset.semantic_layers.registry import register_semantic_layer
 
     def semantic_layer_impl(
         id: str,
@@ -273,7 +309,11 @@ def inject_semantic_layer_implementations() -> None:
             cls.name = name
             cls.description = description
             cls._semantic_layer_id = prefixed_id
-            registry[prefixed_id] = cls
+            register_semantic_layer(
+                prefixed_id,
+                cls,
+                context.extension if context is not None else None,
+            )
             return cls
 
         return decorator
@@ -290,15 +330,18 @@ def inject_storage_implementations() -> None:
     import superset_core.extensions.storage.ephemeral as core_ephemeral_state
     import superset_core.extensions.storage.models as core_storage_models
     import superset_core.extensions.storage.persistent as core_persistent_state
+    import superset_core.extensions.storage.secrets as core_secrets_state
 
     from superset.extensions.storage.ephemeral import EphemeralState
     from superset.extensions.storage.persistent import PersistentState
     from superset.extensions.storage.persistent_dao import ExtensionStorageDAO
     from superset.extensions.storage.persistent_model import ExtensionStorage
+    from superset.extensions.storage.secrets import SecretsState
 
     # Replace abstract classes with concrete implementations
     core_ephemeral_state.EphemeralState = EphemeralState  # type: 
ignore[misc,assignment]
     core_persistent_state.PersistentState = PersistentState  # type: 
ignore[misc,assignment]
+    core_secrets_state.SecretsState = SecretsState  # type: 
ignore[misc,assignment]
     core_storage_models.ExtensionStorageEntry = ExtensionStorage  # type: 
ignore[misc,assignment]
     core_storage_dao.ExtensionStorageDAO = ExtensionStorageDAO  # type: 
ignore[misc,assignment]
 
diff --git a/superset/extensions/context.py b/superset/extensions/context.py
index 7d11822657c..7ba9c1d5635 100644
--- a/superset/extensions/context.py
+++ b/superset/extensions/context.py
@@ -50,12 +50,19 @@ class ExtensionStorage:
 
         return PersistentState
 
+    @property
+    def secrets(self) -> Any:
+        from superset.extensions.storage.secrets import SecretsState
+
+        return SecretsState
+
 
 class ConcreteExtensionContext:
     """Concrete implementation of ExtensionContext for the host."""
 
-    def __init__(self, manifest: Manifest):
+    def __init__(self, manifest: Manifest, user_id: int | None = None):
         self._manifest = manifest
+        self._user_id = user_id
         self._storage = ExtensionStorage()
 
     @property
@@ -67,6 +74,11 @@ class ConcreteExtensionContext:
     def storage(self) -> ExtensionStorage:
         return self._storage
 
+    @property
+    def user_id(self) -> int | None:
+        """Explicit principal for extension execution outside a request."""
+        return self._user_id
+
 
 # Context variable for ambient extension context pattern.
 # Thread-safe and async-safe via Python's contextvars.
@@ -119,7 +131,10 @@ def use_context(ctx: ConcreteExtensionContext) -> 
Iterator[None]:
 
 
 @contextmanager
-def extension_context(manifest: Manifest) -> 
Iterator[ConcreteExtensionContext]:
+def extension_context(
+    manifest: Manifest,
+    user_id: int | None = None,
+) -> Iterator[ConcreteExtensionContext]:
     """
     Context manager for setting extension context during loading.
 
@@ -129,6 +144,6 @@ def extension_context(manifest: Manifest) -> 
Iterator[ConcreteExtensionContext]:
     :param manifest: The extension manifest
     :yields: The created ExtensionContext
     """
-    ctx = ConcreteExtensionContext(manifest)
+    ctx = ConcreteExtensionContext(manifest, user_id=user_id)
     with use_context(ctx):
         yield ctx
diff --git a/superset/extensions/storage/__init__.py 
b/superset/extensions/storage/__init__.py
index 8f8a59b45c2..b1b83f04acd 100644
--- a/superset/extensions/storage/__init__.py
+++ b/superset/extensions/storage/__init__.py
@@ -26,3 +26,4 @@ from superset.extensions.storage.ephemeral import (  # noqa: 
F401
     EphemeralState,
     SharedEphemeralStateAccessor,
 )
+from superset.extensions.storage.secrets import SecretsState  # noqa: F401
diff --git a/superset/extensions/storage/api.py 
b/superset/extensions/storage/api.py
index 340825ffe9e..96e8424b6e0 100644
--- a/superset/extensions/storage/api.py
+++ b/superset/extensions/storage/api.py
@@ -33,6 +33,7 @@ from typing import Any
 from flask import g, request
 from flask.wrappers import Response
 from flask_appbuilder.api import BaseApi, expose, protect, safe
+from superset_core.extensions.storage.models import StorageAccess
 
 from superset.extensions.storage.codecs import DEFAULT_CODEC, get_codec, 
SAFE_CODECS
 from superset.extensions.storage.ephemeral_dao import (
@@ -471,6 +472,7 @@ class ExtensionStorageRestApi(BaseApi):
                 resource_uuid=resource_uuid,
                 page=page,
                 page_size=page_size,
+                access=StorageAccess.FRONTEND,
             )
         except ExtensionStorageListPayloadTooLarge as ex:
             return self.response(ex.status, message=ex.message)
@@ -560,7 +562,12 @@ class ExtensionStorageRestApi(BaseApi):
 
         shared = request.args.get("shared", "false").lower() == "true"
         user_fk = None if shared else g.user.id
-        entry = ExtensionStorageDAO.get(extension_id, key, user_fk=user_fk)
+        entry = ExtensionStorageDAO.get(
+            extension_id,
+            key,
+            user_fk=user_fk,
+            access=StorageAccess.FRONTEND,
+        )
         if entry is None:
             return self.response(200, result=None)
         if entry.codec not in SAFE_CODECS:
@@ -686,6 +693,7 @@ class ExtensionStorageRestApi(BaseApi):
                 codec=codec,
                 user_fk=user_fk,
                 encrypt=encrypt,
+                access=StorageAccess.FRONTEND,
             )
         except (
             ExtensionStorageKeyTooLong,
@@ -745,6 +753,11 @@ class ExtensionStorageRestApi(BaseApi):
 
         shared = request.args.get("shared", "false").lower() == "true"
         user_fk = None if shared else g.user.id
-        ExtensionStorageDAO.delete_by_key(extension_id, key, user_fk=user_fk)
+        ExtensionStorageDAO.delete_by_key(
+            extension_id,
+            key,
+            user_fk=user_fk,
+            access=StorageAccess.FRONTEND,
+        )
 
         return self.response(200, message="Value deleted successfully")
diff --git a/superset/extensions/storage/persistent_dao.py 
b/superset/extensions/storage/persistent_dao.py
index e150a054564..db224f9969f 100644
--- a/superset/extensions/storage/persistent_dao.py
+++ b/superset/extensions/storage/persistent_dao.py
@@ -17,6 +17,8 @@
 
 from __future__ import annotations
 
+import ast
+import base64
 import hashlib
 import hmac
 import logging
@@ -26,7 +28,9 @@ from typing import Any
 import backoff
 from flask import current_app
 from flask_babel import gettext as _
-from sqlalchemy import and_, desc, func, LargeBinary
+from sqlalchemy import and_, desc, func, String
+from sqlalchemy.engine import Dialect
+from superset_core.extensions.storage.models import StorageAccess
 
 from superset import db
 from superset.daos.base import BaseDAO
@@ -124,6 +128,8 @@ MAX_KEY_LENGTH = 255
 #: a crashed holder doesn't block the same key for long.
 SET_LOCK_TTL_SECONDS = 5
 
+ENCRYPTED_VALUE_PREFIX = "superset-extension-storage:v1:"
+
 
 def _validate_key_length(key: str) -> None:
     """Validate key length against MAX_KEY_LENGTH. Raises if over."""
@@ -157,12 +163,42 @@ def _enc_type(user_fk: int | None) -> EncryptedType:
         "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE", DEFAULT_ENCRYPTION_ENGINE_NAME
     )
     return EncryptedType(
-        LargeBinary,
+        String,
         key=key,
         engine=resolve_encryption_engine(engine_name),
     )
 
 
+def _encrypt_value(value: bytes, user_fk: int | None, dialect: Dialect) -> 
bytes:
+    """Encrypt arbitrary bytes using a versioned, text-safe representation."""
+    plaintext = ENCRYPTED_VALUE_PREFIX + 
base64.b64encode(value).decode("ascii")
+    encrypted = _enc_type(user_fk).process_bind_param(plaintext, dialect)
+    if not isinstance(encrypted, bytes):
+        raise TypeError("Encrypted extension storage values must be bytes")
+    return encrypted
+
+
+def _decrypt_value(value: bytes, user_fk: int | None, dialect: Dialect) -> 
bytes:
+    """Decrypt bytes written by ``_encrypt_value`` or the legacy 
implementation."""
+    plaintext = _enc_type(user_fk).process_result_value(value, dialect)
+    if not isinstance(plaintext, str):
+        raise TypeError("Decrypted extension storage values must be strings")
+    if plaintext.startswith(ENCRYPTED_VALUE_PREFIX):
+        return base64.b64decode(
+            plaintext.removeprefix(ENCRYPTED_VALUE_PREFIX),
+            validate=True,
+        )
+
+    # The initial implementation passed bytes directly to EncryptedType. The
+    # resulting plaintext is the Python bytes representation (for example,
+    # ``b'{\"token\": \"...\"}'``). Recover those rows so deployments that
+    # wrote secrets before this encoding was introduced do not lose them.
+    legacy_value = ast.literal_eval(plaintext)
+    if not isinstance(legacy_value, bytes):
+        raise TypeError("Legacy encrypted extension storage value is not 
bytes")
+    return legacy_value
+
+
 def _get_quota() -> int | None:
     """Return the configured per-extension persistent storage quota in bytes.
 
@@ -234,9 +270,7 @@ def _decrypt_if_needed(
     if not entry.is_encrypted:
         return entry.value
     try:
-        return _enc_type(entry.user_fk).process_result_value(
-            entry.value, db.engine.dialect
-        )
+        return _decrypt_value(entry.value, entry.user_fk, db.engine.dialect)
     except Exception:  # noqa: BLE001
         logger.error(
             "Failed to decrypt extension storage value for "
@@ -253,11 +287,13 @@ def _scope_filter(
     user_fk: int | None = None,
     resource_type: str | None = None,
     resource_uuid: str | None = None,
+    access: StorageAccess = StorageAccess.FRONTEND,
 ) -> list[object]:
     """Build the SQLAlchemy filter list for a scoped lookup."""
     filters: list[object] = [
         ExtensionStorage.extension_id == extension_id,
         ExtensionStorage.key == key,
+        ExtensionStorage.access == access.value,
     ]
     if user_fk is None:
         filters.append(ExtensionStorage.user_fk.is_(None))
@@ -279,13 +315,17 @@ def _list_scope_filter(
     user_fk: int | None = None,
     resource_type: str | None = None,
     resource_uuid: str | None = None,
+    access: StorageAccess = StorageAccess.FRONTEND,
 ) -> list[object]:
     """Build the SQLAlchemy filter list for a `list()` call.
 
     Same scope semantics as `_scope_filter` (an explicit None means "match
     the global/unset scope"), minus the `key` filter.
     """
-    filters: list[object] = [ExtensionStorage.extension_id == extension_id]
+    filters: list[object] = [
+        ExtensionStorage.extension_id == extension_id,
+        ExtensionStorage.access == access.value,
+    ]
     if user_fk is None:
         filters.append(ExtensionStorage.user_fk.is_(None))
     else:
@@ -357,6 +397,7 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         user_fk: int | None = None,
         resource_type: str | None = None,
         resource_uuid: str | None = None,
+        access: StorageAccess = StorageAccess.FRONTEND,
     ) -> ExtensionStorage | None:
         """Return the raw storage entry. The value field may be encrypted."""
         entry = (
@@ -364,7 +405,12 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
             .filter(
                 and_(
                     *_scope_filter(
-                        extension_id, key, user_fk, resource_type, 
resource_uuid
+                        extension_id,
+                        key,
+                        user_fk,
+                        resource_type,
+                        resource_uuid,
+                        access,
                     )
                 )
             )
@@ -379,10 +425,11 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         user_fk: int | None = None,
         resource_type: str | None = None,
         resource_uuid: str | None = None,
+        access: StorageAccess = StorageAccess.FRONTEND,
     ) -> 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
+            extension_id, key, user_fk, resource_type, resource_uuid, access
         )
         if entry is None:
             return None
@@ -395,13 +442,14 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         user_fk: int | None = None,
         resource_type: str | None = None,
         resource_uuid: str | None = None,
+        access: StorageAccess = StorageAccess.FRONTEND,
     ) -> Any:
         """Return the value decoded with the codec it was written with.
 
         :returns: The decoded value, or None if not found.
         """
         entry = ExtensionStorageDAO.get(
-            extension_id, key, user_fk, resource_type, resource_uuid
+            extension_id, key, user_fk, resource_type, resource_uuid, access
         )
         if entry is None:
             return None
@@ -432,6 +480,7 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         resource_uuid: str | None = None,
         page: int = 0,
         page_size: int = 10,
+        access: StorageAccess = StorageAccess.FRONTEND,
     ) -> tuple[list[ExtensionStorageListEntry], int]:
         """List entries in the given scope, most recently changed first.
 
@@ -454,7 +503,7 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
             combined value size exceeds MAX_LIST_PAYLOAD_SIZE.
         """
         filters = _list_scope_filter(
-            extension_id, user_fk, resource_type, resource_uuid
+            extension_id, user_fk, resource_type, resource_uuid, access
         )
         base_query = db.session.query(ExtensionStorage).filter(and_(*filters))
         total_count = base_query.count()
@@ -510,8 +559,12 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         resource_type: str | None = None,
         resource_uuid: str | None = None,
         encrypt: bool = False,
+        access: StorageAccess = StorageAccess.FRONTEND,
     ) -> ExtensionStorage:
-        """Upsert a storage entry.  Encrypts value when encrypt=True.
+        """Upsert a storage entry.
+
+        Values are encrypted when ``encrypt`` is true. Backend-only values
+        are always encrypted regardless of the caller-supplied flag.
 
         The select-then-insert/update below is guarded by a distributed lock
         scoped to the whole extension, for two independent reasons:
@@ -543,10 +596,9 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         """
         _validate_key_length(key)
         _validate_value_size(value)
+        encrypt = encrypt or access is StorageAccess.BACKEND
         stored_value = (
-            _enc_type(user_fk).process_bind_param(value, db.engine.dialect)
-            if encrypt
-            else value
+            _encrypt_value(value, user_fk, db.engine.dialect) if encrypt else 
value
         )
 
         with DistributedLock(
@@ -559,7 +611,12 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
                 .filter(
                     and_(
                         *_scope_filter(
-                            extension_id, key, user_fk, resource_type, 
resource_uuid
+                            extension_id,
+                            key,
+                            user_fk,
+                            resource_type,
+                            resource_uuid,
+                            access,
                         )
                     )
                 )
@@ -573,6 +630,7 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
                 entry.value_size = new_size
                 entry.codec = codec
                 entry.is_encrypted = encrypt
+                entry.access = access.value
             else:
                 entry = ExtensionStorage(
                     extension_id=extension_id,
@@ -584,6 +642,7 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
                     resource_type=resource_type,
                     resource_uuid=resource_uuid,
                     is_encrypted=encrypt,
+                    access=access.value,
                 )
                 db.session.add(entry)
             db.session.flush()
@@ -629,6 +688,7 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
         user_fk: int | None = None,
         resource_type: str | None = None,
         resource_uuid: str | None = None,
+        access: StorageAccess = StorageAccess.FRONTEND,
     ) -> bool:
         """Delete a single entry by key. Returns True if a row was removed.
 
@@ -642,7 +702,12 @@ class ExtensionStorageDAO(BaseDAO[ExtensionStorage]):
             .filter(
                 and_(
                     *_scope_filter(
-                        extension_id, key, user_fk, resource_type, 
resource_uuid
+                        extension_id,
+                        key,
+                        user_fk,
+                        resource_type,
+                        resource_uuid,
+                        access,
                     )
                 )
             )
diff --git a/superset/extensions/storage/persistent_model.py 
b/superset/extensions/storage/persistent_model.py
index 1adc15db719..f71282a18e4 100644
--- a/superset/extensions/storage/persistent_model.py
+++ b/superset/extensions/storage/persistent_model.py
@@ -31,6 +31,7 @@ from sqlalchemy import (
 from sqlalchemy_utils import UUIDType
 from superset_core.extensions.storage.models import (
     ExtensionStorageEntry as CoreExtensionStorageEntry,
+    StorageAccess,
 )
 
 from superset.models.helpers import AuditMixinNullable
@@ -103,6 +104,12 @@ class ExtensionStorage(CoreExtensionStorageEntry, 
AuditMixinNullable, Model):
     value_size = Column(Integer, nullable=False)
     codec = Column(String(255), nullable=False, default="json")
     is_encrypted = Column(Boolean, nullable=False, default=False)
+    access = Column(
+        String(32),
+        nullable=False,
+        default=StorageAccess.FRONTEND.value,
+        server_default=StorageAccess.FRONTEND.value,
+    )
 
     __table_args__ = (
         # user_fk, resource_type, and resource_uuid are nullable, and
@@ -114,6 +121,7 @@ class ExtensionStorage(CoreExtensionStorageEntry, 
AuditMixinNullable, Model):
             "user_fk",
             "resource_type",
             "resource_uuid",
+            "access",
             "key",
             name="uq_extension_storage_scoped_key",
         ),
@@ -124,6 +132,7 @@ class ExtensionStorage(CoreExtensionStorageEntry, 
AuditMixinNullable, Model):
             "user_fk",
             "resource_type",
             "resource_uuid",
+            "access",
             "key",
         ),
         # Covers both extension_id-only lookups (leftmost prefix) and the
diff --git a/superset/extensions/storage/secrets.py 
b/superset/extensions/storage/secrets.py
new file mode 100644
index 00000000000..35398fdd8c0
--- /dev/null
+++ b/superset/extensions/storage/secrets.py
@@ -0,0 +1,78 @@
+# 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 of backend-only extension secrets storage."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset_core.extensions.storage.models import StorageAccess
+from superset_core.extensions.storage.secrets import SecretsState as 
CoreSecretsState
+
+from superset.extensions.storage.codecs import DEFAULT_CODEC, get_codec
+from superset.extensions.storage.persistent_dao import ExtensionStorageDAO
+from superset.extensions.storage.utils import (
+    get_current_extension_id,
+    get_current_user_id,
+)
+from superset.utils.decorators import transaction
+
+
+class SecretsState(CoreSecretsState):
+    """Encrypted persistent secrets scoped to the current extension and 
user."""
+
+    @staticmethod
+    def get(key: str) -> Any:
+        """Return the current user's decoded secret, or ``None`` when 
absent."""
+        extension_id = get_current_extension_id("secrets_state")
+        user_id = get_current_user_id("secrets_state")
+        return ExtensionStorageDAO.get_decoded_value(
+            extension_id,
+            key,
+            user_fk=user_id,
+            access=StorageAccess.BACKEND,
+        )
+
+    @staticmethod
+    @transaction()
+    def set(key: str, value: Any) -> None:
+        """Store a JSON-encoded secret encrypted at rest."""
+        extension_id = get_current_extension_id("secrets_state")
+        user_id = get_current_user_id("secrets_state")
+        ExtensionStorageDAO.set(
+            extension_id,
+            key,
+            get_codec(DEFAULT_CODEC).encode(value),
+            codec=DEFAULT_CODEC,
+            user_fk=user_id,
+            encrypt=True,
+            access=StorageAccess.BACKEND,
+        )
+
+    @staticmethod
+    @transaction()
+    def remove(key: str) -> None:
+        """Remove the current user's secret."""
+        extension_id = get_current_extension_id("secrets_state")
+        user_id = get_current_user_id("secrets_state")
+        ExtensionStorageDAO.delete_by_key(
+            extension_id,
+            key,
+            user_fk=user_id,
+            access=StorageAccess.BACKEND,
+        )
diff --git a/superset/extensions/storage/utils.py 
b/superset/extensions/storage/utils.py
index bdd9a537a9d..57cb37529b4 100644
--- a/superset/extensions/storage/utils.py
+++ b/superset/extensions/storage/utils.py
@@ -103,7 +103,10 @@ def get_current_user_id(caller: str) -> int:
     :param caller: Name of the calling accessor (e.g. "ephemeral_state",
         "persistent_state"), used in the error message.
     """
-    user_id = get_user_id()
+    context = get_current_extension_context()
+    user_id = context.user_id if context is not None else None
+    if user_id is None:
+        user_id = get_user_id()
     if user_id is None:
         raise RuntimeError(
             f"{caller} requires an authenticated user. "
diff --git 
a/superset/migrations/versions/2026-09-28_00-00_4c8f2a1d9b73_add_extension_storage_access.py
 
b/superset/migrations/versions/2026-09-28_00-00_4c8f2a1d9b73_add_extension_storage_access.py
new file mode 100644
index 00000000000..58ca5cddc85
--- /dev/null
+++ 
b/superset/migrations/versions/2026-09-28_00-00_4c8f2a1d9b73_add_extension_storage_access.py
@@ -0,0 +1,95 @@
+# 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 browser access classification to extension storage.
+
+Revision ID: 4c8f2a1d9b73
+Revises: 95d8a99c822e
+Create Date: 2026-09-28 00:00:00.000000
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "4c8f2a1d9b73"
+down_revision = "95d8a99c822e"
+
+_LOOKUP_COLUMNS = [
+    "extension_id",
+    "user_fk",
+    "resource_type",
+    "resource_uuid",
+    "access",
+    "key",
+]
+_LEGACY_LOOKUP_COLUMNS = [
+    "extension_id",
+    "user_fk",
+    "resource_type",
+    "resource_uuid",
+    "key",
+]
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("extension_storage") as batch_op:
+        batch_op.add_column(
+            sa.Column(
+                "access",
+                sa.String(length=32),
+                server_default="frontend",
+                nullable=False,
+            )
+        )
+        batch_op.drop_constraint(
+            "uq_extension_storage_scoped_key",
+            type_="unique",
+        )
+        batch_op.create_unique_constraint(
+            "uq_extension_storage_scoped_key",
+            _LOOKUP_COLUMNS,
+        )
+        batch_op.drop_index("ix_ext_storage_lookup")
+        batch_op.create_index(
+            "ix_ext_storage_lookup",
+            _LOOKUP_COLUMNS,
+            unique=False,
+        )
+
+
+def downgrade() -> None:
+    # Backend entries must never become browser-readable after a downgrade.
+    op.execute(
+        sa.delete(sa.table("extension_storage", sa.column("access"))).where(
+            sa.column("access") == "backend"
+        )
+    )
+    with op.batch_alter_table("extension_storage") as batch_op:
+        batch_op.drop_constraint(
+            "uq_extension_storage_scoped_key",
+            type_="unique",
+        )
+        batch_op.create_unique_constraint(
+            "uq_extension_storage_scoped_key",
+            _LEGACY_LOOKUP_COLUMNS,
+        )
+        batch_op.drop_index("ix_ext_storage_lookup")
+        batch_op.create_index(
+            "ix_ext_storage_lookup",
+            _LEGACY_LOOKUP_COLUMNS,
+            unique=False,
+        )
+        batch_op.drop_column("access")
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index ab7447644c1..6d6e0e9b16c 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import logging
+from dataclasses import asdict
 from typing import Any
 
 from flask import make_response, request, Response
@@ -27,6 +28,7 @@ from flask_babel import lazy_gettext as t, ngettext
 from marshmallow import ValidationError
 from pydantic import ValidationError as PydanticValidationError
 from sqlalchemy.orm import load_only, Query
+from superset_core.semantic_layers.layer import 
SemanticLayerAuthenticationRequiredError
 
 from superset import db, event_logger, is_feature_enabled, security_manager
 from superset.commands.semantic_layer.create import (
@@ -53,6 +55,7 @@ from superset.commands.semantic_layer.exceptions import (
     SemanticViewUpdateFailedError,
 )
 from superset.commands.semantic_layer.update import (
+    unmask_stored_configuration,
     UpdateSemanticLayerCommand,
     UpdateSemanticViewCommand,
 )
@@ -64,7 +67,7 @@ from superset.exceptions import SupersetSecurityException
 from superset.models.core import Database
 from superset.semantic_layers.masking import mask_configuration
 from superset.semantic_layers.models import SemanticLayer, SemanticView
-from superset.semantic_layers.registry import registry
+from superset.semantic_layers.registry import registry, semantic_layer_context
 from superset.semantic_layers.schemas import (
     SemanticLayerPostSchema,
     SemanticLayerPutSchema,
@@ -575,6 +578,9 @@ class SemanticLayerRestApi(BaseSupersetApi):
         **MODEL_API_RW_METHOD_PERMISSION_MAP,
         "types": "read",
         "configuration_schema": "read",
+        "configuration_action": "write",
+        "configuration_actions": "read",
+        "semantic_layer_action": "read",
         "runtime_schema": "read",
         # ``read`` (not the default ``can_views`` / ``can_connections``) so
         # these stay broadly accessible: ``SemanticLayer`` is in
@@ -645,37 +651,167 @@ class SemanticLayerRestApi(BaseSupersetApi):
         if not cls:
             return self.response_400(message=f"Unknown type: {sl_type}")
 
-        parsed_config = None
-        if config := body.get("configuration"):
-            parsed_config = _parse_partial_config(cls, config)
+        with semantic_layer_context(sl_type):  # type: ignore[arg-type]
+            parsed_config = None
+            if config := body.get("configuration"):
+                parsed_config = _parse_partial_config(cls, config)
 
-        warning: str | None = None
-        try:
-            schema = cls.get_configuration_schema(parsed_config)
-        except Exception:  # pylint: disable=broad-except
-            # Show a stable, user-friendly message in the UI; the exception
-            # detail goes to the server log below.
-            warning = str(
-                t(
-                    "Could not load metadata for this configuration; "
-                    "showing the default form. See the server logs for 
details."
+            warning: str | None = None
+            try:
+                schema = cls.get_configuration_schema(parsed_config)
+            except Exception:  # pylint: disable=broad-except
+                # Show a stable, user-friendly message in the UI; the exception
+                # detail goes to the server log below.
+                warning = str(
+                    t(
+                        "Could not load metadata for this configuration; "
+                        "showing the default form. See the server logs for 
details."
+                    )
                 )
-            )
-            logger.exception(
-                "Error enriching semantic layer configuration schema for type 
%s",
-                sl_type,
-            )
-            # Connection or query failures during schema enrichment should not
-            # prevent the form from rendering — return the base schema instead.
-            schema = cls.get_configuration_schema(None)
+                logger.exception(
+                    "Error enriching semantic layer configuration schema for 
type %s",
+                    sl_type,
+                )
+                # Connection or query failures during schema enrichment should 
not
+                # prevent the form from rendering — return the base schema 
instead.
+                schema = cls.get_configuration_schema(None)
 
-        payload: dict[str, Any] = {"result": schema}
+            actions = [
+                asdict(action)
+                for action in cls.get_configuration_actions(parsed_config)
+            ]
+
+        payload: dict[str, Any] = {"result": schema, "actions": actions}
         if warning:
             payload["warning"] = warning
         resp = make_response(json.dumps(payload, sort_keys=False), 200)
         resp.headers["Content-Type"] = "application/json; charset=utf-8"
         return resp
 
+    @expose(
+        "/type/<sl_type>/actions/<action_id>",
+        methods=("POST",),
+    )
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def configuration_action(
+        self,
+        sl_type: str,
+        action_id: str,
+    ) -> FlaskResponse:
+        """Start a configuration action before a semantic layer exists."""
+        cls = registry.get(sl_type)
+        if not cls:
+            return self.response_400(message=f"Unknown type: {sl_type}")
+
+        body = request.json or {}
+        configuration = body.get("configuration") or {}
+        if not isinstance(configuration, dict):
+            return self.response_400(message="Configuration must be an object")
+
+        if semantic_layer_uuid := body.get("semantic_layer_uuid"):
+            layer = SemanticLayerDAO.find_by_uuid(semantic_layer_uuid)
+            if not layer:
+                return self.response_404()
+            try:
+                layer.raise_for_access()
+            except SupersetSecurityException as ex:
+                return self.response(403, message=ex.message)
+            if layer.type != sl_type:
+                return self.response_400(
+                    message="Semantic layer type does not match the requested 
type"
+                )
+            try:
+                configuration = unmask_stored_configuration(
+                    layer.configuration,
+                    configuration,
+                    layer.type,
+                )
+            except SemanticLayerInvalidError as ex:
+                return self.response_400(message=str(ex))
+
+        with semantic_layer_context(sl_type):
+            parsed_config = _parse_partial_config(cls, configuration)
+            try:
+                result = cls.execute_configuration_action(
+                    action_id,
+                    parsed_config,
+                    body.get("return_url"),
+                )
+            except ValueError as ex:
+                return self.response_400(message=str(ex))
+
+        return self.response(200, result=asdict(result))
+
+    @expose("/<uuid>/actions", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def configuration_actions(self, uuid: str) -> FlaskResponse:
+        """Return configuration actions for an existing semantic layer."""
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+        try:
+            layer.raise_for_access()
+        except SupersetSecurityException as ex:
+            return self.response(403, message=ex.message)
+
+        cls = registry.get(layer.type)
+        if not cls:
+            return self.response_400(message=f"Unknown type: {layer.type}")
+        configuration = layer.configuration
+        if isinstance(configuration, str):
+            configuration = json.loads(configuration)
+
+        with semantic_layer_context(layer.type):
+            parsed_config = _parse_partial_config(cls, configuration or {})
+            actions = [
+                asdict(action)
+                for action in cls.get_configuration_actions(parsed_config)
+            ]
+        return self.response(200, result=actions)
+
+    @expose("/<uuid>/actions/<action_id>", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def semantic_layer_action(
+        self,
+        uuid: str,
+        action_id: str,
+    ) -> FlaskResponse:
+        """Start a personal configuration action for an existing layer."""
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+        try:
+            layer.raise_for_access()
+        except SupersetSecurityException as ex:
+            return self.response(403, message=ex.message)
+
+        cls = registry.get(layer.type)
+        if not cls:
+            return self.response_400(message=f"Unknown type: {layer.type}")
+        configuration = layer.configuration
+        if isinstance(configuration, str):
+            configuration = json.loads(configuration)
+        body = request.get_json(silent=True) or {}
+
+        with semantic_layer_context(layer.type):
+            parsed_config = _parse_partial_config(cls, configuration or {})
+            try:
+                result = cls.execute_configuration_action(
+                    action_id,
+                    parsed_config,
+                    body.get("return_url"),
+                )
+            except ValueError as ex:
+                return self.response_400(message=str(ex))
+        return self.response(200, result=asdict(result))
+
     @expose("/<uuid>/schema/runtime", methods=("POST",))
     @protect()
     @safe
@@ -728,10 +864,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
             return self.response_400(message=f"Unknown type: {layer.type}")
 
         try:
-            schema = cls.get_runtime_schema(
-                layer.implementation.configuration,  # type: 
ignore[attr-defined]
-                runtime_data,
-            )
+            with semantic_layer_context(layer.type):
+                schema = cls.get_runtime_schema(
+                    layer.implementation.configuration,  # type: 
ignore[attr-defined]
+                    runtime_data,
+                )
         except Exception as ex:  # pylint: disable=broad-except
             return self.response_400(message=str(ex))
 
@@ -786,6 +923,14 @@ class SemanticLayerRestApi(BaseSupersetApi):
 
         try:
             views = layer.implementation.get_semantic_views(runtime_data)
+        except SemanticLayerAuthenticationRequiredError as ex:
+            return self.response(
+                428,
+                message=ex.message,
+                error_type="SEMANTIC_LAYER_AUTHENTICATION_REQUIRED",
+                action_id=ex.action_id,
+                semantic_layer_uuid=str(layer.uuid),
+            )
         except Exception as ex:  # pylint: disable=broad-except
             logger.error(
                 "Error fetching semantic views for layer %s: %s",
diff --git a/superset/semantic_layers/masking.py 
b/superset/semantic_layers/masking.py
index b0c07f30ea6..5bc957586a4 100644
--- a/superset/semantic_layers/masking.py
+++ b/superset/semantic_layers/masking.py
@@ -66,7 +66,7 @@ from typing import Any
 from superset_core.semantic_layers.layer import SemanticLayer as 
CoreSemanticLayer
 
 from superset.constants import PASSWORD_MASK
-from superset.semantic_layers.registry import registry
+from superset.semantic_layers.registry import registry, semantic_layer_context
 
 logger: logging.Logger = logging.getLogger(__name__)
 
@@ -361,7 +361,8 @@ def mask_configuration(layer_type: str, configuration: Any) 
-> dict[str, Any]:
         # The connector's own published shape (the same source #43474's
         # top-level masker used); this walker extends it to nested/union
         # secrets rather than only top-level ``writeOnly`` properties.
-        schema: JsonSchema = cls.get_configuration_schema()
+        with semantic_layer_context(layer_type):
+            schema: JsonSchema = cls.get_configuration_schema()
     except Exception:  # pylint: disable=broad-except
         logger.warning(
             "Masking semantic layer type %s: schema generation failed", 
layer_type
diff --git a/superset/semantic_layers/models.py 
b/superset/semantic_layers/models.py
index dbb9eba1ce3..b09ddb0b826 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -60,7 +60,11 @@ from superset.extensions import encrypted_field_factory
 from superset.models.helpers import AuditMixinNullable, QueryResult
 from superset.result_set import stringify_extension_columns
 from superset.semantic_layers.mapper import get_results
-from superset.semantic_layers.registry import registry
+from superset.semantic_layers.registry import (
+    contextualize_semantic_layer,
+    registry,
+    semantic_layer_context,
+)
 from superset.utils import json
 from superset.utils.core import GenericDataType
 
@@ -267,7 +271,9 @@ class SemanticLayer(AuditMixinNullable, Model):
         # TODO (betodealmeida):
         # return extension_manager.get_contribution("semanticLayers", 
self.type)
         class_ = registry[self.type]
-        return class_.from_configuration(json.loads(self.configuration))
+        with semantic_layer_context(self.type):
+            implementation = 
class_.from_configuration(json.loads(self.configuration))
+        return contextualize_semantic_layer(self.type, implementation)
 
 
 class SemanticView(AuditMixinNullable, Model):
diff --git a/superset/semantic_layers/registry.py 
b/superset/semantic_layers/registry.py
index 076d1aa87de..81e3c5d5917 100644
--- a/superset/semantic_layers/registry.py
+++ b/superset/semantic_layers/registry.py
@@ -17,8 +17,152 @@
 
 from __future__ import annotations
 
-from typing import Any
+from contextlib import contextmanager, nullcontext
+from typing import Any, Iterator
 
+from superset_core.extensions.types import Manifest
 from superset_core.semantic_layers.layer import SemanticLayer
 
+from superset.extensions.context import (
+    extension_context,
+    get_current_extension_context,
+)
+from superset.utils.core import get_user_id
+
 registry: dict[str, type[SemanticLayer[Any, Any]]] = {}
+_extension_manifests: dict[str, Manifest] = {}
+
+
+def register_semantic_layer(
+    semantic_layer_id: str,
+    implementation: type[SemanticLayer[Any, Any]],
+    manifest: Manifest | None = None,
+) -> None:
+    """Register a provider and retain the extension that owns it."""
+    registry[semantic_layer_id] = implementation
+    if manifest is None:
+        _extension_manifests.pop(semantic_layer_id, None)
+    else:
+        _extension_manifests[semantic_layer_id] = manifest
+
+
+def unregister_semantic_layer(semantic_layer_id: str) -> None:
+    """Remove a provider and any retained extension metadata."""
+    registry.pop(semantic_layer_id, None)
+    _extension_manifests.pop(semantic_layer_id, None)
+
+
+@contextmanager
+def semantic_layer_context(semantic_layer_id: str) -> Iterator[None]:
+    """Run provider code in its owning extension and principal context."""
+    manifest = _extension_manifests.get(semantic_layer_id)
+    current_context = get_current_extension_context()
+    user_id = (
+        current_context.user_id
+        if current_context is not None and current_context.user_id is not None
+        else get_user_id()
+    )
+    context = (
+        extension_context(manifest, user_id=user_id)
+        if manifest is not None
+        else nullcontext()
+    )
+    with context:
+        yield
+
+
+class ContextualSemanticView:
+    """Forward semantic view operations inside their extension context."""
+
+    def __init__(self, semantic_layer_id: str, implementation: Any):
+        self._semantic_layer_id = semantic_layer_id
+        self._implementation = implementation
+
+    def __getattr__(self, name: str) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return getattr(self._implementation, name)
+
+    def uid(self) -> str:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.uid()
+
+    def get_dimensions(self) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_dimensions()
+
+    def get_metrics(self) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_metrics()
+
+    def get_values(self, dimension: Any, filters: Any = None) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_values(dimension, filters)
+
+    def get_table(self, query: Any) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_table(query)
+
+    def get_row_count(self, query: Any) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_row_count(query)
+
+    def get_compatible_metrics(
+        self,
+        selected_metrics: Any,
+        selected_dimensions: Any,
+    ) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_compatible_metrics(
+                selected_metrics,
+                selected_dimensions,
+            )
+
+    def get_compatible_dimensions(
+        self,
+        selected_metrics: Any,
+        selected_dimensions: Any,
+    ) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return self._implementation.get_compatible_dimensions(
+                selected_metrics,
+                selected_dimensions,
+            )
+
+
+class ContextualSemanticLayer:
+    """Forward semantic layer operations inside their extension context."""
+
+    def __init__(self, semantic_layer_id: str, implementation: Any):
+        self._semantic_layer_id = semantic_layer_id
+        self._implementation = implementation
+
+    def __getattr__(self, name: str) -> Any:
+        with semantic_layer_context(self._semantic_layer_id):
+            return getattr(self._implementation, name)
+
+    def get_semantic_views(self, runtime_configuration: dict[str, Any]) -> 
set[Any]:
+        with semantic_layer_context(self._semantic_layer_id):
+            views = 
self._implementation.get_semantic_views(runtime_configuration)
+        return {ContextualSemanticView(self._semantic_layer_id, view) for view 
in views}
+
+    def get_semantic_view(
+        self,
+        name: str,
+        additional_configuration: dict[str, Any],
+    ) -> ContextualSemanticView:
+        with semantic_layer_context(self._semantic_layer_id):
+            view = self._implementation.get_semantic_view(
+                name,
+                additional_configuration,
+            )
+        return ContextualSemanticView(self._semantic_layer_id, view)
+
+
+def contextualize_semantic_layer(
+    semantic_layer_id: str,
+    implementation: Any,
+) -> Any:
+    """Wrap extension providers while leaving host providers unchanged."""
+    if semantic_layer_id not in _extension_manifests:
+        return implementation
+    return ContextualSemanticLayer(semantic_layer_id, implementation)
diff --git a/tests/unit_tests/extensions/api_context_test.py 
b/tests/unit_tests/extensions/api_context_test.py
new file mode 100644
index 00000000000..058146e4108
--- /dev/null
+++ b/tests/unit_tests/extensions/api_context_test.py
@@ -0,0 +1,52 @@
+# 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 unittest.mock import MagicMock, patch
+
+from flask_appbuilder.api import expose
+
+from superset.core.api.core_api_injection import _contextualize_extension_api
+
+
+def test_extension_api_methods_run_in_extension_context() -> None:
+    manifest = MagicMock()
+
+    class TestApi:
+        @expose("/callback", methods=("GET",))
+        def callback(self) -> str:
+            return "ok"
+
+        def helper(self) -> str:
+            return "helper"
+
+    original_helper = TestApi.helper
+    with (
+        patch(
+            "superset.core.api.core_api_injection.extension_context"
+        ) as extension_context,
+        patch(
+            "superset.core.api.core_api_injection.get_user_id",
+            return_value=42,
+        ),
+    ):
+        _contextualize_extension_api(TestApi, manifest)
+
+        assert TestApi().callback() == "ok"
+
+    extension_context.assert_called_once_with(manifest, user_id=42)
+    assert TestApi.helper is original_helper
+    assert TestApi.callback._urls == [("/callback", ("GET",))]
diff --git a/tests/unit_tests/extensions/storage/test_api.py 
b/tests/unit_tests/extensions/storage/test_api.py
index e835fb8443d..e3f7a158603 100644
--- a/tests/unit_tests/extensions/storage/test_api.py
+++ b/tests/unit_tests/extensions/storage/test_api.py
@@ -32,6 +32,7 @@ from unittest.mock import MagicMock, patch
 
 from flask import Flask, g
 from flask_babel import Babel
+from superset_core.extensions.storage.models import StorageAccess
 
 from superset.extensions.storage.api import ExtensionStorageRestApi
 from superset.extensions.storage.codecs import get_codec
@@ -733,6 +734,7 @@ def test_persistent_set_defaults_codec_to_json(
             codec="json",
             user_fk=42,
             encrypt=False,
+            access=StorageAccess.FRONTEND,
         )
 
 
@@ -773,6 +775,7 @@ def test_persistent_set_with_binary_flag_decodes_base64(
             codec="binary",
             user_fk=42,
             encrypt=False,
+            access=StorageAccess.FRONTEND,
         )
 
 
@@ -943,6 +946,7 @@ def test_list_persistent_returns_decoded_entries_and_count(
             resource_uuid=None,
             page=0,
             page_size=10,
+            access=StorageAccess.FRONTEND,
         )
 
 
@@ -1063,6 +1067,7 @@ def test_list_persistent_passes_query_params_to_dao(
             resource_uuid="uuid-1",
             page=2,
             page_size=25,
+            access=StorageAccess.FRONTEND,
         )
 
 
diff --git a/tests/unit_tests/extensions/storage/test_context.py 
b/tests/unit_tests/extensions/storage/test_context.py
index 732b08ae09d..f3ffaebf6ac 100644
--- a/tests/unit_tests/extensions/storage/test_context.py
+++ b/tests/unit_tests/extensions/storage/test_context.py
@@ -98,7 +98,7 @@ def test_context_provides_extension_property():
 
 
 def test_context_provides_storage_property():
-    """Context provides storage property with ephemeral tier."""
+    """Context provides browser storage and backend secrets accessors."""
     manifest = create_manifest()
     ctx = ConcreteExtensionContext(manifest)
 
@@ -106,6 +106,16 @@ def test_context_provides_storage_property():
         storage = get_context().storage
         assert storage is not None
         assert hasattr(storage, "ephemeral")
+        assert hasattr(storage, "persistent")
+        assert hasattr(storage, "secrets")
+
+
+def test_extension_context_carries_explicit_user_id():
+    """Background execution can carry the initiating principal explicitly."""
+    manifest = create_manifest()
+
+    with extension_context(manifest, user_id=42) as ctx:
+        assert ctx.user_id == 42
 
 
 def test_context_exception_still_restores():
diff --git a/tests/unit_tests/extensions/storage/test_persistent_dao.py 
b/tests/unit_tests/extensions/storage/test_persistent_dao.py
index 40181bc47de..401f54b46ef 100644
--- a/tests/unit_tests/extensions/storage/test_persistent_dao.py
+++ b/tests/unit_tests/extensions/storage/test_persistent_dao.py
@@ -25,13 +25,17 @@ from unittest.mock import MagicMock, Mock, patch
 import pytest
 from flask import Flask
 from flask_babel import Babel
+from sqlalchemy.dialects.sqlite import dialect
 from sqlalchemy.orm.session import Session
+from superset_core.extensions.storage.models import StorageAccess
 
 from superset.extensions.storage.codecs import get_codec
 from superset.extensions.storage.filters import ExtensionStorageFilter
 from superset.extensions.storage.persistent_dao import (
+    _decrypt_value,
     _derive_key,
     _enc_type,
+    _encrypt_value,
     ExtensionStorageDAO,
     ExtensionStorageKeyTooLong,
     ExtensionStorageListPayloadTooLarge,
@@ -124,6 +128,7 @@ def _create_entry(
     resource_type: str | None = None,
     resource_uuid: str | None = None,
     is_encrypted: bool = False,
+    access: StorageAccess = StorageAccess.FRONTEND,
 ) -> ExtensionStorage:
     """Insert an ExtensionStorage row directly, bypassing DAO.set() (whose
     own validation isn't what these `list()` tests are exercising)."""
@@ -137,6 +142,7 @@ def _create_entry(
         resource_type=resource_type,
         resource_uuid=resource_uuid,
         is_encrypted=is_encrypted,
+        access=access.value,
     )
     session.add(entry)
     session.flush()
@@ -231,7 +237,7 @@ def test_dao_get_value_decrypts_encrypted_entry(
     entry.value = b"encrypted-bytes"
     mock_db.session.query.return_value.filter.return_value.first.return_value 
= entry
     mock_enc_type.return_value.process_result_value.return_value = (
-        b'{"decrypted": true}'
+        "superset-extension-storage:v1:eyJkZWNyeXB0ZWQiOiB0cnVlfQ=="
     )
 
     with app.app_context():
@@ -375,7 +381,7 @@ def test_dao_set_encrypts_value_when_requested(
 
     mock_enc_type.assert_called_once_with(1)
     mock_enc_type.return_value.process_bind_param.assert_called_once_with(
-        b"plaintext", mock_db.engine.dialect
+        "superset-extension-storage:v1:cGxhaW50ZXh0", mock_db.engine.dialect
     )
     added_entry = mock_db.session.add.call_args[0][0]
     assert added_entry.value == b"ciphertext"
@@ -383,6 +389,34 @@ def test_dao_set_encrypts_value_when_requested(
     assert added_entry.is_encrypted is True
 
 
+@patch("superset.extensions.storage.persistent_dao._enc_type")
+@patch("superset.extensions.storage.persistent_dao.db")
+def test_dao_set_forces_encryption_for_backend_access(
+    mock_db: MagicMock,
+    mock_enc_type: MagicMock,
+    app: Flask,
+) -> None:
+    """Backend-only values are encrypted even when encrypt is omitted."""
+    mock_db.session.query.return_value.filter.return_value.first.return_value 
= None
+    mock_enc_type.return_value.process_bind_param.return_value = b"ciphertext"
+
+    with app.app_context():
+        ExtensionStorageDAO.set(
+            "my-ext",
+            "token",
+            b"plaintext",
+            user_fk=1,
+            access=StorageAccess.BACKEND,
+        )
+
+    added_entry = mock_db.session.add.call_args[0][0]
+    assert added_entry.is_encrypted is True
+    assert added_entry.access == StorageAccess.BACKEND.value
+    mock_enc_type.return_value.process_bind_param.assert_called_once_with(
+        "superset-extension-storage:v1:cGxhaW50ZXh0", mock_db.engine.dialect
+    )
+
+
 # ── value size 
────────────────────────────────────────────────────────────────
 
 
@@ -710,6 +744,32 @@ def test_enc_type_user_key_differs_from_shared_key(app: 
Flask) -> None:
         assert enc_user.key != secret
 
 
[email protected]("engine", ["aes", "aes-gcm"])
+def test_encrypted_value_round_trip_preserves_arbitrary_bytes(
+    app: Flask, engine: str
+) -> None:
+    """The real encryption types round-trip binary codec output."""
+    value = b"\x00\x80binary\xff"
+    app.config["SQLALCHEMY_ENCRYPTED_FIELD_ENGINE"] = engine
+
+    with app.app_context():
+        encrypted = _encrypt_value(value, user_fk=42, dialect=dialect())
+        assert _decrypt_value(encrypted, user_fk=42, dialect=dialect()) == 
value
+
+
+def test_decrypt_value_reads_legacy_bytes_representation(app: Flask) -> None:
+    """Secrets written by the initial byte-based implementation remain 
readable."""
+    with app.app_context():
+        encrypted = _enc_type(user_fk=42).process_bind_param(
+            b'{"access_token":"token"}',
+            dialect(),
+        )
+
+        assert _decrypt_value(encrypted, user_fk=42, dialect=dialect()) == (
+            b'{"access_token":"token"}'
+        )
+
+
 # ── list 
──────────────────────────────────────────────────────────────────────
 
 
@@ -758,6 +818,28 @@ def test_list_scopes_by_user_fk(app: Flask, list_session: 
Session) -> None:
     assert entries[0].key == "mine"
 
 
+def test_list_scopes_by_access(app: Flask, list_session: Session) -> None:
+    """Frontend listing cannot count or return backend-only entries."""
+    _create_entry(list_session, "my-ext", "preference", user_fk=1)
+    _create_entry(
+        list_session,
+        "my-ext",
+        "oauth2",
+        user_fk=1,
+        access=StorageAccess.BACKEND,
+    )
+
+    with app.app_context():
+        entries, total_count = ExtensionStorageDAO.list_entries(
+            "my-ext",
+            user_fk=1,
+            access=StorageAccess.FRONTEND,
+        )
+
+    assert total_count == 1
+    assert entries[0].key == "preference"
+
+
 def test_list_scopes_by_resource(app: Flask, list_session: Session) -> None:
     """list() only returns entries matching the given resource_type/uuid."""
     _create_entry(
@@ -935,7 +1017,9 @@ def test_list_decrypts_encrypted_entries(
         
mock_db.session.query.return_value.filter.return_value.order_by.return_value
     )
     page_query.offset.return_value.limit.return_value.all.return_value = 
[entry]
-    mock_enc_type.return_value.process_result_value.return_value = b"plaintext"
+    mock_enc_type.return_value.process_result_value.return_value = (
+        "superset-extension-storage:v1:cGxhaW50ZXh0"
+    )
 
     with app.app_context():
         entries, total_count = ExtensionStorageDAO.list_entries("my-ext", 
user_fk=1)
diff --git a/tests/unit_tests/extensions/storage/test_secrets.py 
b/tests/unit_tests/extensions/storage/test_secrets.py
new file mode 100644
index 00000000000..1f56e010a42
--- /dev/null
+++ b/tests/unit_tests/extensions/storage/test_secrets.py
@@ -0,0 +1,112 @@
+# 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 backend-only extension secrets storage."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask
+from superset_core.extensions.storage.models import StorageAccess
+
+from superset.extensions.context import use_context
+from superset.extensions.storage.secrets import SecretsState
+from superset.utils import json
+from tests.unit_tests.extensions.storage.conftest import create_context, 
set_user
+
+
+def test_secrets_state_requires_extension_context(app: Flask) -> None:
+    """Secrets cannot be accessed outside extension execution."""
+    with app.app_context():
+        set_user(42)
+        with pytest.raises(RuntimeError, match="within an extension context"):
+            SecretsState.get("oauth2")
+
+
+def test_secrets_state_requires_authenticated_user(app: Flask) -> None:
+    """Secrets cannot be accessed without an authenticated principal."""
+    with app.app_context(), use_context(create_context()):
+        with pytest.raises(RuntimeError, match="requires an authenticated 
user"):
+            SecretsState.get("oauth2")
+
+
+@patch("superset.extensions.storage.secrets.ExtensionStorageDAO")
+def test_secrets_state_get_is_user_scoped(
+    mock_dao: MagicMock,
+    app: Flask,
+) -> None:
+    """Secret reads use backend access and the authenticated user."""
+    mock_dao.get_decoded_value.return_value = {"access_token": "token"}
+
+    with app.app_context(), use_context(create_context()):
+        set_user(42)
+        result = SecretsState.get("oauth2")
+
+    mock_dao.get_decoded_value.assert_called_once_with(
+        "test-org.test-ext",
+        "oauth2",
+        user_fk=42,
+        access=StorageAccess.BACKEND,
+    )
+    assert result == {"access_token": "token"}
+
+
+@patch("superset.db")
+@patch("superset.extensions.storage.secrets.ExtensionStorageDAO")
+def test_secrets_state_set_forces_encryption(
+    mock_dao: MagicMock,
+    mock_db: MagicMock,
+    app: Flask,
+) -> None:
+    """Secret writes are JSON encoded, encrypted, and backend-only."""
+    value = {"access_token": "token", "refresh_token": "refresh"}
+
+    with app.app_context(), use_context(create_context()):
+        set_user(42)
+        SecretsState.set("oauth2", value)
+
+    mock_dao.set.assert_called_once_with(
+        "test-org.test-ext",
+        "oauth2",
+        json.dumps(value).encode(),
+        codec="json",
+        user_fk=42,
+        encrypt=True,
+        access=StorageAccess.BACKEND,
+    )
+
+
+@patch("superset.db")
+@patch("superset.extensions.storage.secrets.ExtensionStorageDAO")
+def test_secrets_state_remove_is_backend_only(
+    mock_dao: MagicMock,
+    mock_db: MagicMock,
+    app: Flask,
+) -> None:
+    """Removing a secret cannot remove a frontend entry with the same key."""
+    with app.app_context(), use_context(create_context()):
+        set_user(42)
+        SecretsState.remove("oauth2")
+
+    mock_dao.delete_by_key.assert_called_once_with(
+        "test-org.test-ext",
+        "oauth2",
+        user_fk=42,
+        access=StorageAccess.BACKEND,
+    )
diff --git a/tests/unit_tests/semantic_layers/api_test.py 
b/tests/unit_tests/semantic_layers/api_test.py
index 230f543a1fe..726834375f4 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -25,6 +25,10 @@ import pytest
 from flask.testing import FlaskClient
 from pydantic import BaseModel, Field, model_validator, SecretStr
 from pytest_mock import MockerFixture
+from superset_core.semantic_layers.layer import (
+    SemanticLayerAction,
+    SemanticLayerActionResult,
+)
 from werkzeug.test import TestResponse
 
 from superset.commands.semantic_layer.exceptions import (
@@ -361,9 +365,146 @@ def test_configuration_schema(
 
     assert response.status_code == 200
     assert response.json["result"] == {"type": "object"}
+    assert response.json["actions"] == []
     mock_cls.get_configuration_schema.assert_called_once_with(None)
 
 
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_returns_actions(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Configuration schema reports the current user's provider actions."""
+    mock_cls = MagicMock()
+    mock_cls.get_configuration_schema.return_value = {"type": "object"}
+    mock_cls.get_configuration_actions.return_value = [
+        SemanticLayerAction(
+            id="oauth2",
+            label="Connect to Quiver",
+            status="required",
+            required=True,
+        )
+    ]
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"quiver": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "quiver"},
+    )
+
+    assert response.status_code == 200
+    assert response.json["actions"] == [
+        {
+            "id": "oauth2",
+            "label": "Connect to Quiver",
+            "status": "required",
+            "required": True,
+            "message": None,
+        }
+    ]
+
+
+@SEMANTIC_LAYERS_APP
+def test_execute_configuration_action(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """A provider can start OAuth before a semantic layer row exists."""
+    parsed_config = MagicMock()
+    mock_cls = MagicMock()
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "type": "object",
+        "properties": {},
+    }
+    mock_cls.configuration_class.model_validate.return_value = parsed_config
+    mock_cls.execute_configuration_action.return_value = 
SemanticLayerActionResult(
+        redirect_url="https://auth.example.test/authorize";
+    )
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"quiver": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/type/quiver/actions/oauth2",
+        json={
+            "configuration": {"host": "quiver.example.test"},
+            "return_url": "https://superset.example.test/semantic-layers";,
+        },
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"] == {
+        "redirect_url": "https://auth.example.test/authorize";
+    }
+    mock_cls.execute_configuration_action.assert_called_once_with(
+        "oauth2",
+        parsed_config,
+        "https://superset.example.test/semantic-layers";,
+    )
+
+
+@SEMANTIC_LAYERS_APP
+def test_execute_configuration_action_unmasks_existing_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """An edit action resolves masks against the authorized stored layer."""
+    parsed_config = MagicMock()
+    mock_cls = MagicMock()
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "type": "object",
+        "properties": {},
+    }
+    mock_cls.configuration_class.model_validate.return_value = parsed_config
+    mock_cls.execute_configuration_action.return_value = 
SemanticLayerActionResult(
+        redirect_url="https://auth.example.test/authorize";
+    )
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"quiver": mock_cls},
+        clear=True,
+    )
+    layer = MagicMock()
+    layer.type = "quiver"
+    layer.configuration = '{"client_secret": "stored-secret"}'
+    mocker.patch(
+        "superset.semantic_layers.api.SemanticLayerDAO.find_by_uuid",
+        return_value=layer,
+    )
+    unmask = mocker.patch(
+        "superset.semantic_layers.api.unmask_stored_configuration",
+        return_value={"client_secret": "stored-secret"},
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/type/quiver/actions/oauth2",
+        json={
+            "configuration": {"client_secret": PASSWORD_MASK},
+            "semantic_layer_uuid": "11111111-1111-1111-1111-111111111111",
+        },
+    )
+
+    assert response.status_code == 200
+    layer.raise_for_access.assert_called_once_with()
+    unmask.assert_called_once_with(
+        layer.configuration,
+        {"client_secret": PASSWORD_MASK},
+        "quiver",
+    )
+    mock_cls.execute_configuration_action.assert_called_once_with(
+        "oauth2", parsed_config, None
+    )
+
+
 @SEMANTIC_LAYERS_APP
 def test_configuration_schema_with_partial_config(
     client: Any,
diff --git a/tests/unit_tests/semantic_layers/decorators_test.py 
b/tests/unit_tests/semantic_layers/decorators_test.py
index 9edd4957db5..b3a0eb9d0ba 100644
--- a/tests/unit_tests/semantic_layers/decorators_test.py
+++ b/tests/unit_tests/semantic_layers/decorators_test.py
@@ -40,7 +40,7 @@ def test_inject_semantic_layer_host_context() -> None:
     from superset.core.api.core_api_injection import (
         inject_semantic_layer_implementations,
     )
-    from superset.semantic_layers.registry import registry
+    from superset.semantic_layers.registry import registry, 
unregister_semantic_layer
 
     # Clear registry for test isolation
     registry.clear()
@@ -65,7 +65,7 @@ def test_inject_semantic_layer_host_context() -> None:
     assert FakeLayer.description == "A test"  # type: ignore[attr-defined]
 
     # Cleanup
-    registry.pop("test_layer", None)
+    unregister_semantic_layer("test_layer")
 
 
 def test_inject_semantic_layer_extension_context() -> None:
@@ -73,7 +73,7 @@ def test_inject_semantic_layer_extension_context() -> None:
     from superset.core.api.core_api_injection import (
         inject_semantic_layer_implementations,
     )
-    from superset.semantic_layers.registry import registry
+    from superset.semantic_layers.registry import registry, 
unregister_semantic_layer
 
     registry.clear()
 
@@ -100,4 +100,4 @@ def test_inject_semantic_layer_extension_context() -> None:
     assert registry[expected_id] is ExtLayer
 
     # Cleanup
-    registry.pop(expected_id, None)
+    unregister_semantic_layer(expected_id)
diff --git a/tests/unit_tests/semantic_layers/registry_test.py 
b/tests/unit_tests/semantic_layers/registry_test.py
new file mode 100644
index 00000000000..07116eabb41
--- /dev/null
+++ b/tests/unit_tests/semantic_layers/registry_test.py
@@ -0,0 +1,69 @@
+# 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 contextual semantic layer extension execution."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from flask import Flask
+
+from superset.extensions.context import get_context
+from superset.semantic_layers.registry import (
+    contextualize_semantic_layer,
+    register_semantic_layer,
+    semantic_layer_context,
+    unregister_semantic_layer,
+)
+from tests.unit_tests.extensions.storage.conftest import create_manifest
+
+
+def test_semantic_layer_context_restores_extension_and_user(app: Flask) -> 
None:
+    """Registered extension providers receive their manifest and principal."""
+    manifest = create_manifest("acme", "semantic")
+    implementation_class = MagicMock()
+    semantic_layer_id = "extensions.acme.semantic.test"
+    register_semantic_layer(semantic_layer_id, implementation_class, manifest)
+
+    try:
+        with app.app_context():
+            with semantic_layer_context(semantic_layer_id):
+                assert get_context().extension is manifest
+    finally:
+        unregister_semantic_layer(semantic_layer_id)
+
+
+def test_contextual_layer_wraps_returned_view(app: Flask) -> None:
+    """Calls on views returned by an extension retain extension context."""
+    manifest = create_manifest("acme", "semantic")
+    semantic_layer_id = "extensions.acme.semantic.test"
+    implementation_class = MagicMock()
+    register_semantic_layer(semantic_layer_id, implementation_class, manifest)
+
+    view = MagicMock()
+    view.uid.side_effect = lambda: get_context().extension.id
+    layer = MagicMock()
+    layer.get_semantic_view.return_value = view
+
+    try:
+        contextual_layer = contextualize_semantic_layer(semantic_layer_id, 
layer)
+        with app.app_context():
+            contextual_view = contextual_layer.get_semantic_view("orders", {})
+            assert contextual_view.uid() == "acme.semantic"
+    finally:
+        unregister_semantic_layer(semantic_layer_id)

Reply via email to