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)
