diff --git a/UPDATING.md b/UPDATING.md index 3e564449ed8d..614f4bab66ac 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -734,6 +734,16 @@ floats), and non-finite Decimals in those columns become null. Other chart result serialization and chart number formatting are unchanged. Re-run queries whose cached JSON results were produced before upgrading all workers. + +### Downgrading backend-only extension secrets + +Migration `4c8f2a1d9b73` adds backend-only extension storage for personal secrets, +including OAuth tokens. Downgrading past this migration permanently deletes all +storage rows marked `access=backend` before removing the access classification. +This prevents secrets from becoming readable through the older browser storage +API. Back up the metadata database before downgrading. Users must reconnect their +personal accounts if they upgrade again after the downgrade. + ### Version history retention setting Use `VERSION_HISTORY_RETENTION_DAYS` for both the application setting and diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index 563aa8ae540a..88c7df9598f1 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -266,3 +266,60 @@ class MySemanticLayer(SemanticLayer[MyConfig, MySemanticView]): ``` See [Semantic Layers Extension Point](./extension-points/semantic-layers.md) for implementation details. + +#### Personal OAuth2 connections + +Providers can expose a personal connection action by implementing +`get_configuration_actions(configuration)` and +`execute_configuration_action(action_id, configuration, return_url)`. The first +hook returns `SemanticLayerAction` entries describing whether the authenticated +user is connected. The second returns a `SemanticLayerActionResult` containing +the provider's authorization URL. Providers without connection actions can keep +the default implementations. + +Store access and refresh tokens using the Python-only +[`get_context().storage.secrets` accessor](./storage.md#backend-only-secrets). +Never put personal credentials in the shared layer configuration, action results, +query results, logs, or exceptions. The host executes provider methods under the +owning extension and requesting user's context, including background chart work. +Semantic View query results, cached dimension values, and shared async query tasks +are partitioned by user, +including providers without connection actions. This reduces cache sharing for +providers with identical results for all users, but declaring no actions does not +establish that a provider returns user-independent data. + +When any provider operation requires authentication, raise the core exception: + +```python +from superset_core.semantic_layers.layer import SemanticLayerAuthenticationRequiredError + +raise SemanticLayerAuthenticationRequiredError("oauth2") +``` + +Superset translates this into the existing `OAUTH2_REDIRECT` error contract for +view discovery, metadata, filter values, and chart queries. The authorization +link opens a protected start route, which checks access to the originating layer +or specific view before invoking the provider's action. A grant on one view is +sufficient to connect a personal account; it does not grant access to sibling +views or permission to edit the layer. + +The host supplies a completion URL as `return_url`. The provider must preserve +that URL in its server-side OAuth state and redirect there **after** validating +the callback and durably saving the authenticated user's tokens. OAuth state, +PKCE validation, token exchange, refresh, and revocation remain the provider's +responsibility. The host completion page sends a correlation ID through +`BroadcastChannel` and the storage-event fallback; it never sends credentials. +The original tab then refreshes the configuration form or retries the failed +operation. Returning browser focus before authorization completes does not +consume the completion notification. Closing an unfinished connection window +allows the user to start another attempt. + +The completion URL uses the request's externally visible host and scheme. Operators +must configure their reverse proxy, forwarded headers, and allowed hosts so +Superset sees its trusted public HTTPS origin. See the +[reverse proxy configuration](/admin-docs/configuration/configuring-superset#configuration-behind-a-load-balancer). + +Action POST responses contain `result.redirect_url` and a host-generated +`result.tab_id`. Both the configuration modal and the generic OAuth error UI +use the same completion listener. No access or refresh token is returned in +these responses. diff --git a/docs/developer_docs/extensions/storage.md b/docs/developer_docs/extensions/storage.md index 93c13b07ff00..d1e633f621fb 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-CBC by default) can be changed for the whole deployment via the existing `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE` config, the same setting that controls encryption for database credentials elsewhere in Superset — there is no separate key list or rotation mechanism specific to extension storage. When `SECRET_KEY` is rotated, extension storage rows are re-encrypted by the existing `SecretsMigrator` tooling alongside database credentials, with no extension-specific steps required. ### Quotas diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index 028af7aa04bb..e8b1ff657a07 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -35169,6 +35169,57 @@ ] } }, + "/api/v1/semantic_layer/oauth2/complete/{tab_id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "tab_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "HTML page that signals completion without exposing tokens" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Notify the initiating tab that OAuth authorization completed", + "tags": [ + "Semantic Layers" + ] + } + }, "/api/v1/semantic_layer/schema/configuration": { "post": { "requestBody": { @@ -35214,6 +35265,102 @@ ] } }, + "/api/v1/semantic_layer/type/{sl_type}/actions/{action_id}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "sl_type", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "configuration": { + "type": "object" + }, + "semantic_layer_uuid": { + "description": "Existing layer whose masked configuration to resolve", + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "properties": { + "redirect_url": { + "format": "uri", + "type": "string" + }, + "tab_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "Authorization URL and connection correlation ID" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Start a semantic layer configuration action", + "tags": [ + "Semantic Layers" + ] + } + }, "/api/v1/semantic_layer/types": { "get": { "responses": { @@ -35448,6 +35595,187 @@ ] } }, + "/api/v1/semantic_layer/{uuid}/actions": { + "get": { + "parameters": [ + { + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Semantic view through which the user accesses the layer", + "in": "query", + "name": "view_uuid", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "items": { + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "message": { + "nullable": true, + "type": "string" + }, + "required": { + "type": "boolean" + }, + "status": { + "enum": [ + "required", + "connected", + "expired", + "error" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Available configuration actions and their connection status" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "List personal configuration actions for a semantic layer", + "tags": [ + "Semantic Layers" + ] + } + }, + "/api/v1/semantic_layer/{uuid}/actions/{action_id}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Semantic view through which the user accesses the layer", + "in": "query", + "name": "view_uuid", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "properties": { + "redirect_url": { + "format": "uri", + "type": "string" + }, + "tab_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "Authorization URL and connection correlation ID" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Start a personal configuration action for a semantic layer", + "tags": [ + "Semantic Layers" + ] + } + }, "/api/v1/semantic_layer/{uuid}/clear_cache": { "post": { "parameters": [ @@ -35505,6 +35833,89 @@ ] } }, + "/api/v1/semantic_layer/{uuid}/oauth2/{action_id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Correlation ID for the initiating browser tab", + "in": "query", + "name": "tab_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Semantic view through which the user accesses the layer", + "in": "query", + "name": "view_uuid", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to the authorization provider", + "headers": { + "Location": { + "description": "Provider authorization URL", + "schema": { + "format": "uri", + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Redirect to the semantic layer's authorization provider", + "tags": [ + "Semantic Layers" + ] + } + }, "/api/v1/semantic_layer/{uuid}/schema/runtime": { "post": { "parameters": [ diff --git a/superset-core/src/superset_core/extensions/context.py b/superset-core/src/superset_core/extensions/context.py index 63c8ea35e78c..a9a3a45a488e 100644 --- a/superset-core/src/superset_core/extensions/context.py +++ b/superset-core/src/superset_core/extensions/context.py @@ -46,6 +46,7 @@ def setup(): 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 @@ def persistent(self) -> "PersistentStateAccessor": """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 095b1b9c9cb1..e0c4359e5fff 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 @@ - 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 @@ 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 847052ed021f..64384edba0ee 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 @@ from __future__ import annotations +from enum import Enum from typing import TYPE_CHECKING from uuid import UUID @@ -39,6 +40,13 @@ 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 fa2b7e5ad312..8f3f9ffb1718 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 @@ # 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-core/src/superset_core/extensions/storage/secrets.py b/superset-core/src/superset_core/extensions/storage/secrets.py new file mode 100644 index 000000000000..c4053775b68b --- /dev/null +++ b/superset-core/src/superset_core/extensions/storage/secrets.py @@ -0,0 +1,41 @@ +# 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. + +"""Backend-only, encrypted storage for user-scoped extension secrets.""" + +from __future__ import annotations + +from typing import 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 8e6b02ef7314..76eb0bd48532 100644 --- a/superset-core/src/superset_core/semantic_layers/layer.py +++ b/superset-core/src/superset_core/semantic_layers/layer.py @@ -20,7 +20,7 @@ import enum from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, ClassVar, Generic, Mapping, TypeVar +from typing import Any, ClassVar, Generic, Literal, Mapping, TypeVar from pydantic import BaseModel @@ -75,6 +75,33 @@ class SemanticCacheIdentityMaterial: values: Mapping[str, object] +@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. @@ -114,6 +141,29 @@ def get_semantic_cache_context_identity( # None preserves legacy result-cache keys without constructing the provider. result_cache_version: ClassVar[str | None] = None + @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 a personal action and return its authorization URL. + + Preserve the host-supplied ``return_url`` in server-side OAuth state. + After validating the callback and durably storing tokens, redirect there + to notify the initiating tab. Never include credentials in the result. + """ + raise ValueError(f"Unknown semantic layer action: {action_id}") + @classmethod @abstractmethod def from_configuration( diff --git a/superset-core/src/superset_core/tasks/types.py b/superset-core/src/superset_core/tasks/types.py index 39b2edbcf4b3..fa4db4ac68ce 100644 --- a/superset-core/src/superset_core/tasks/types.py +++ b/superset-core/src/superset_core/tasks/types.py @@ -87,6 +87,8 @@ class TaskProperties(TypedDict, total=False): # failure reason (public); the exception class and traceback are internal # debug detail and live under ``private["framework"]`` instead. error_message: str + # Actionable OAuth errors; credentials and provider exceptions are excluded. + errors: list[dict[str, Any]] # Internal runtime state, surfaced to user-facing API payloads only in debug # mode (the Task REST API strips this key otherwise). Holds framework/task diff --git a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx index 4642fb23c3a6..b3dcc079e3c7 100644 --- a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx +++ b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx @@ -16,8 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useRef } from 'react'; - import { useDispatch, useSelector } from 'react-redux'; import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; import { ExplorePageState } from 'src/explore/types'; @@ -27,50 +25,63 @@ import { triggerQuery } from 'src/components/Chart/chartAction'; import { onRefresh } from 'src/dashboard/actions/dashboardState'; import { UNSAVED_CHART_ID } from 'src/explore/constants'; import { api } from 'src/hooks/apiResources/queryApi'; +import useOAuth2Completion from 'src/hooks/useOAuth2Completion'; import { t } from '@apache-superset/core/translation'; import { QueryResponse } from '@superset-ui/core'; import type { ErrorMessageComponentProps } from './types'; import { ErrorAlert } from './ErrorAlert'; -const OAUTH_CHANNEL_NAME = 'oauth'; -const OAUTH_STORAGE_EVENT_KEY = 'oauth2_auth_complete'; - interface OAuth2RedirectExtra { url: string; tab_id: string; } -/* - * Component for starting OAuth2 dance. - * - * When a user without credentials tries to access a database that supports OAuth2, the - * backend will raise an exception with the custom error `OAUTH2_REDIRECT`. This will - * cause the frontend to display this component, which informs the user that they need - * to authenticate in order to access the data. - * - * The component has a URL that is used to start the OAuth2 dance for the given - * database. When the user clicks the link a new browser tab will open, where they can - * authorize Superset to access the data. Once authorization is successful the user will - * be redirected back to Superset, and their personal access token is stored, so it can - * be used in subsequent connections. If a refresh token is also present in the response, - * it will also be stored. - * - * After the token has been stored, the opened tab will broadcast a message to the - * original tab and close itself. This component, running on the original tab, listens - * for same-origin BroadcastChannel and storage notifications and re-runs the query - * for the user once it receives the success message — be it in SQL Lab, Explore, or - * a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is - * echoed back so the original tab only reacts to its own OAuth2 flow. - */ -export function OAuth2RedirectMessage({ +function OAuth2RedirectPrompt({ + error, + closable, + onComplete, +}: ErrorMessageComponentProps & { + onComplete: () => void | boolean; +}) { + const { extra, level } = error; + useOAuth2Completion(extra.tab_id, onComplete); + + const body = ( +

+ {t( + 'This data source uses OAuth2 for authentication. Please click the link above to grant Apache Superset permission to access the data. Your personal access token will be stored encrypted and used only for queries run by you.', + )} +

+ ); + const subtitle = ( + <> + {t('You need to')}{' '} + + {t('provide authorization')} + {' '} + {t('in order to run this operation.')} + + ); + + return ( + + ); +} + +function OAuth2RedirectMessageWithRedux({ error, source, closable, errorMitigationFunction, }: ErrorMessageComponentProps) { - const { extra, level } = error; - // state needed for re-running the SQL Lab query const queries = useSelector< SqlLabRootState, @@ -104,104 +115,70 @@ export function OAuth2RedirectMessage({ ); const dispatch = useDispatch(); - const lastHandledTabIdRef = useRef(); - - useEffect(() => { - const handleOAuthComplete = (tabId?: string) => { - if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) { - return; - } - - if (errorMitigationFunction) { - errorMitigationFunction(); - } else if (source === 'sqllab' && query) { - dispatch(reRunQuery(query)); - } else if (source === 'explore') { - dispatch(triggerQuery(true, chartId)); - } else if (source === 'dashboard') { - dispatch(onRefresh(chartList.map(Number), true, 0, dashboardId)); - } else if (source === 'crud') { - dispatch( - api.util.invalidateTags([ - { type: 'Schemas', id: 'LIST' }, - { type: 'Catalogs', id: 'LIST' }, - 'Tables', - 'TableMetadatas', - ]), - ); - } else { - return; - } - - lastHandledTabIdRef.current = tabId; - }; - - const channel = - typeof BroadcastChannel !== 'undefined' - ? new BroadcastChannel(OAUTH_CHANNEL_NAME) - : null; - - if (channel) { - channel.onmessage = event => { - handleOAuthComplete(event.data?.tabId); - }; + const handleComplete = () => { + if (errorMitigationFunction) { + errorMitigationFunction(); + } else if (source === 'sqllab' && query) { + dispatch(reRunQuery(query)); + } else if (source === 'explore') { + dispatch(triggerQuery(true, chartId)); + } else if (source === 'dashboard') { + dispatch(onRefresh(chartList.map(Number), true, 0, dashboardId)); + } else if (source === 'crud') { + dispatch( + api.util.invalidateTags([ + { type: 'Schemas', id: 'LIST' }, + { type: 'Catalogs', id: 'LIST' }, + 'Tables', + 'TableMetadatas', + ]), + ); + } else { + return false; } - - const handleStorage = (event: StorageEvent) => { - if (event.key !== OAUTH_STORAGE_EVENT_KEY || !event.newValue) { - return; - } - - try { - const message = JSON.parse(event.newValue) as { tabId?: string }; - handleOAuthComplete(message.tabId); - } catch { - // ignore malformed storage payloads - } - }; - - window.addEventListener('storage', handleStorage); - - return () => { - window.removeEventListener('storage', handleStorage); - channel?.close(); - }; - }, [ - source, - extra.tab_id, - dispatch, - query, - chartId, - chartList, - dashboardId, - errorMitigationFunction, - ]); - - const body = ( -

- {t( - 'This database uses OAuth2 for authentication. Please click the link above to grant Apache Superset permission to access the data. Your personal access token will be stored encrypted and used only for queries run by you.', - )} -

- ); - const subtitle = ( - <> - {t('You need to')}{' '} - - {t('provide authorization')} - {' '} - {t('in order to run this operation.')} - - ); - + return true; + }; return ( - ); } + +/* + * Component for starting OAuth2 dance. + * + * When a user without credentials tries to access a database that supports OAuth2, the + * backend will raise an exception with the custom error `OAUTH2_REDIRECT`. This will + * cause the frontend to display this component, which informs the user that they need + * to authenticate in order to access the data. + * + * The component has a URL that is used to start the OAuth2 dance for the given + * database. When the user clicks the link a new browser tab will open, where they can + * authorize Superset to access the data. Once authorization is successful the user will + * be redirected back to Superset, and their personal access token is stored, so it can + * be used in subsequent connections. If a refresh token is also present in the response, + * it will also be stored. + * + * After the token has been stored, the opened tab will broadcast a message to the + * original tab and close itself. This component, running on the original tab, listens + * for same-origin BroadcastChannel and storage notifications and re-runs the query + * for the user once it receives the success message — be it in SQL Lab, Explore, or + * a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is + * echoed back so the original tab only reacts to its own OAuth2 flow. + */ +export function OAuth2RedirectMessage( + props: ErrorMessageComponentProps, +) { + if (props.errorMitigationFunction) { + return ( + + ); + } + return ; +} diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx index 67f278dbaa3f..00da8be5dd6b 100644 --- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx @@ -745,3 +745,129 @@ test('A pending clear prevents edits and a closed editor ignores its late respon await act(async () => finish({ json: {} })); expect(props.addSuccessToast).not.toHaveBeenCalled(); }); + +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', + tab_id: 'oauth-tab', + }, + }, + }); + const popup = { + close: jest.fn(), + location: { replace: jest.fn() }, + } as unknown as Window; + const open = jest.spyOn(window, 'open').mockReturnValue(popup); + + render(); + + 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/example/actions/oauth2', + jsonPayload: { + configuration: expect.any(Object), + semantic_layer_uuid: props.semanticLayerUuid, + }, + }); + expect(open).toHaveBeenCalledWith('', '_blank'); + expect(popup.opener).toBeNull(); + expect(popup.location.replace).toHaveBeenCalledWith( + 'https://auth.example.test/authorize', + ); + expect(open).toHaveBeenCalledTimes(1); + }); + + const callsBeforeCompletion = mockedPost.mock.calls.length; + fireEvent.focus(window); + expect(mockedPost).toHaveBeenCalledTimes(callsBeforeCompletion); + fireEvent( + window, + new StorageEvent('storage', { + key: 'oauth2_auth_complete', + newValue: JSON.stringify({ tabId: 'unrelated-flow' }), + }), + ); + expect(mockedPost).toHaveBeenCalledTimes(callsBeforeCompletion); + fireEvent( + window, + new StorageEvent('storage', { + key: 'oauth2_auth_complete', + newValue: JSON.stringify({ tabId: 'oauth-tab' }), + }), + ); + await waitFor(() => + expect(mockedPost).toHaveBeenCalledTimes(callsBeforeCompletion + 1), + ); + open.mockRestore(); +}); + +test('allows another connection attempt after the popup is cancelled', async () => { + mockedPost + .mockResolvedValueOnce({ + json: { + result: schemaWithExternalDeps, + actions: [ + { + id: 'oauth2', + label: 'Connect', + status: 'required', + required: true, + }, + ], + }, + }) + .mockResolvedValueOnce({ + json: { + result: { + redirect_url: 'https://auth.example.test/authorize', + tab_id: 'cancelled-flow', + }, + }, + }); + const popup = { + close: jest.fn(), + closed: false, + location: { replace: jest.fn() }, + }; + const open = jest + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as Window); + render(); + const connect = await screen.findByRole('button', { name: 'Connect' }); + fireEvent.click(connect); + await waitFor(() => + expect(popup.location.replace).toHaveBeenCalledWith( + 'https://auth.example.test/authorize', + ), + ); + expect(connect).toBeDisabled(); + popup.closed = true; + await act(async () => { + jest.advanceTimersByTime(500); + }); + expect(connect).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + open.mockRestore(); +}); diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx index 301a2900c97f..2409371a372f 100644 --- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx @@ -33,6 +33,8 @@ import { MODAL_MEDIUM_WIDTH, } from 'src/components/Modal'; import { styled } from '@apache-superset/core/theme'; +import useOAuth2Completion from 'src/hooks/useOAuth2Completion'; +import { navigateOpenedTab, openBlankTab } from 'src/utils/navigationUtils'; import { renderers, sanitizeSchema, @@ -79,6 +81,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; @@ -101,6 +111,10 @@ export default function SemanticLayerModal({ const [types, setTypes] = useState([]); const [loading, setLoading] = useState(false); const [configSchema, setConfigSchema] = useState(null); + const [configurationActions, setConfigurationActions] = useState< + SemanticLayerAction[] + >([]); + const [runningActionId, setRunningActionId] = useState(null); const [uiSchema, setUiSchema] = useState( undefined, ); @@ -132,6 +146,8 @@ export default function SemanticLayerModal({ useState('ValidateAndHide'); const errorsRef = useRef([]); const debounceTimerRef = useRef | null>(null); + const [oauthTabId, setOauthTabId] = useState(); + const oauthPopupRef = useRef(null); const lastDepSnapshotRef = useRef(''); const dynamicDepsRef = useRef>({}); const schemaRequestRef = useRef(0); @@ -179,6 +195,7 @@ export default function SemanticLayerModal({ }); if (request !== schemaRequestRef.current) return; applySchema(json.result); + setConfigurationActions(json.actions ?? []); if (json.warning) { addDangerToast(configurationErrorMessage()); } @@ -227,6 +244,8 @@ export default function SemanticLayerModal({ if (request !== schemaRequestRef.current) return; applySchema(schemaJson.result); if (schemaJson.warning) addDangerToast(configurationErrorMessage()); + + setConfigurationActions(schemaJson.actions ?? []); setStep('config'); } catch { if (request !== schemaRequestRef.current) return; @@ -249,11 +268,15 @@ export default function SemanticLayerModal({ fetchTypes(); } } else { + setOauthTabId(undefined); + oauthPopupRef.current = null; setStep('type'); setName(''); setSelectedType(null); setTypes([]); setConfigSchema(null); + setConfigurationActions([]); + setRunningActionId(null); setUiSchema(undefined); setFormData({}); setSavedForm(null); @@ -304,6 +327,25 @@ export default function SemanticLayerModal({ } }; + useOAuth2Completion(oauthTabId, () => { + setOauthTabId(undefined); + setRunningActionId(null); + oauthPopupRef.current = null; + if (selectedType) fetchConfigSchema(selectedType, formData); + }); + + useEffect(() => { + if (!oauthTabId) return undefined; + const timer = window.setInterval(() => { + if (oauthPopupRef.current?.closed) { + setOauthTabId(undefined); + setRunningActionId(null); + oauthPopupRef.current = null; + } + }, 500); + return () => window.clearInterval(timer); + }, [oauthTabId]); + const handleStepAdvance = () => { if (selectedType) { // Pre-fill the Name with the type's display name so a user who just @@ -323,6 +365,7 @@ export default function SemanticLayerModal({ setRefreshingSchema(false); setStep('type'); setConfigSchema(null); + setConfigurationActions([]); setUiSchema(undefined); setFormData({}); setValidationMode('ValidateAndHide'); @@ -362,6 +405,51 @@ export default function SemanticLayerModal({ } }; + const handleConfigurationAction = async (action: SemanticLayerAction) => { + if (!selectedType) return; + const popup = openBlankTab(); + 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, + ...(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')); + } + const tabId = json.result?.tab_id; + if (typeof tabId !== 'string') { + throw new Error(t('The connection action did not return a tab ID')); + } + oauthPopupRef.current = popup; + setOauthTabId(tabId); + navigateOpenedTab(popup, redirectUrl); + } 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')), + ); + 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. @@ -468,6 +556,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 ( + {configurationActions.map(action => ( + + + {action.message &&
{action.message}
} +
+ ))} {configSchema && ( // Wrap in a form with autocomplete="off" so browsers do not // autofill credential fields (service token, account, etc.). diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx index eac84aaa3d0d..a4e2d60b15eb 100644 --- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx +++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx @@ -756,6 +756,10 @@ test('the save payload carries every selected metric', async () => { await waitFor( () => { + expect(mockedPost).toHaveBeenCalledWith({ + endpoint: '/api/v1/semantic_layer/layer-1/views', + jsonPayload: { runtime_data: { metrics: ['m1', 'm2', 'm3'] } }, + }); expect( screen.getByRole('combobox', { name: 'Semantic views' }), ).toBeEnabled(); @@ -1677,3 +1681,68 @@ test('a superseded refresh failure cannot toast or clobber a newer response', as fireEvent.mouseDown(schema); expect(await screen.findByText('third_only')).toBeInTheDocument(); }); + +test.each(['runtime', 'views'])( + 'offers OAuth and retries when %s discovery needs a personal token', + async stage => { + mockedGet.mockResolvedValue({ + json: { result: [{ uuid: 'layer-1', name: 'Personal layer' }] }, + }); + let needsAuthentication = true; + const authorizationUrl = '/api/v1/semantic_layer/layer-1/oauth2/connect'; + mockedPost.mockImplementation(({ endpoint }: { endpoint: string }) => { + const isRuntime = endpoint.endsWith('/schema/runtime'); + if ( + needsAuthentication && + (isRuntime ? stage === 'runtime' : stage === 'views') + ) { + return Promise.reject({ + response: new Response( + JSON.stringify({ + errors: [ + { + error_type: 'OAUTH2_REDIRECT', + message: 'Authorization needed', + level: 'warning', + extra: { url: authorizationUrl, tab_id: 'personal-flow' }, + }, + ], + }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ), + }); + } + return Promise.resolve({ + json: { + result: isRuntime + ? { properties: {} } + : [{ name: 'orders', already_added: false }], + }, + }); + }); + const props = createProps(); + render(); + await selectOption('Semantic layer', 'Personal layer'); + const authorize = await screen.findByRole('link', { + name: 'provide authorization', + }); + expect(authorize).toHaveAttribute('href', authorizationUrl); + expect(props.addDangerToast).not.toHaveBeenCalled(); + needsAuthentication = false; + await act(async () => { + window.dispatchEvent( + new StorageEvent('storage', { + key: 'oauth2_auth_complete', + newValue: JSON.stringify({ tabId: 'personal-flow' }), + }), + ); + }); + await waitFor(() => + expect( + screen.queryByRole('link', { name: 'provide authorization' }), + ).not.toBeInTheDocument(), + ); + await selectOption('Semantic views', 'orders'); + expect(screen.getByRole('button', { name: 'Add 1 view(s)' })).toBeEnabled(); + }, +); diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx index 7db276d8f272..a727d67d12ff 100644 --- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx +++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx @@ -20,7 +20,13 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { t } from '@apache-superset/core/translation'; import { logging } from '@apache-superset/core/utils'; import { styled } from '@apache-superset/core/theme'; -import { SupersetClient } from '@superset-ui/core'; +import { + ErrorTypeEnum, + getClientErrorObject, + SupersetClient, + SupersetError, +} from '@superset-ui/core'; +import { OAuth2RedirectMessage } from 'src/components/ErrorMessage'; import { List, Select } from '@superset-ui/core/components'; import { Alert } from '@apache-superset/core/components'; import { Spin } from '@superset-ui/core/components/Spin'; @@ -205,6 +211,43 @@ export default function AddSemanticViewModal({ // flight for a previous dependency state never land. const viewsGenRef = useRef(0); + const [authentication, setAuthentication] = useState<{ + error: SupersetError<{ url: string; tab_id: string }>; + retry: () => void; + }>(); + const captureAuthenticationError = useCallback( + async ( + error: Parameters[0], + retry: () => void, + isCurrent: () => boolean, + ): Promise => { + const parsed = await getClientErrorObject(error); + if (!isCurrent()) return true; + const oauthError = parsed.errors?.find( + item => item.error_type === ErrorTypeEnum.OAUTH2_REDIRECT, + ); + if ( + oauthError && + typeof oauthError.extra?.url === 'string' && + typeof oauthError.extra?.tab_id === 'string' + ) { + setAuthentication({ + error: { + ...oauthError, + extra: { + url: oauthError.extra.url, + tab_id: oauthError.extra.tab_id, + }, + }, + retry, + }); + return true; + } + return false; + }, + [], + ); + // ========================================================================= // Fetch helpers // ========================================================================= @@ -243,14 +286,24 @@ export default function AddSemanticViewModal({ }); if (!isCurrent()) return; setAvailableViews(json.result ?? []); - } catch { + } catch (error) { if (!isCurrent()) return; + if ( + await captureAuthenticationError( + error, + () => { + fetchViews(uuid, rData, gen); + }, + isCurrent, + ) + ) + return; addDangerToast(t('An error occurred while fetching available views')); } finally { if (isCurrent()) setLoadingViews(false); } }, - [addDangerToast], + [addDangerToast, captureAuthenticationError], ); const lastSchemaJsonRef = useRef(''); @@ -318,12 +371,14 @@ export default function AddSemanticViewModal({ // ========================================================================= const handleLayerChange = useCallback( - async (uuid: string) => { + async (uuid: string): Promise => { fetchGenRef.current += 1; const gen = fetchGenRef.current; setSelectedLayerUuid(uuid); setCreationErrors([]); + + setAuthentication(undefined); if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current); if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current); setRuntimeSchema(null); @@ -362,8 +417,18 @@ export default function AddSemanticViewModal({ } else { applyRuntimeSchema(schema); } - } catch { + } catch (error) { if (gen !== fetchGenRef.current) return; + if ( + await captureAuthenticationError( + error, + () => { + handleLayerChange(uuid); + }, + () => gen === fetchGenRef.current, + ) + ) + return; addDangerToast( t('An error occurred while fetching the runtime schema'), ); @@ -371,7 +436,12 @@ export default function AddSemanticViewModal({ if (gen === fetchGenRef.current) setLoadingRuntime(false); } }, - [applyRuntimeSchema, fetchViews, addDangerToast], + [ + applyRuntimeSchema, + fetchViews, + addDangerToast, + captureAuthenticationError, + ], ); // ========================================================================= @@ -465,6 +535,22 @@ export default function AddSemanticViewModal({ refreshGen !== schemaRefreshGenRef.current ) return; + if ( + await captureAuthenticationError( + error, + () => { + lastDepSnapshotRef.current = ''; + handleRuntimeFormChange({ + data, + errors: errorsRef.current, + }); + }, + () => + gen === fetchGenRef.current && + refreshGen === schemaRefreshGenRef.current, + ) + ) + return; logging.error('Runtime schema refresh failed', error); // Allow exactly one retry per dependency state: clearing the // committed snapshot lets the next change event re-attempt @@ -524,6 +610,7 @@ export default function AddSemanticViewModal({ addDangerToast, staleSchemaOptions, runtimeData, + captureAuthenticationError, ], ); @@ -586,6 +673,7 @@ export default function AddSemanticViewModal({ if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current); if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current); setLayers([]); + setAuthentication(undefined); setSelectedLayerUuid(null); setLoadingLayers(false); setRuntimeSchema(null); @@ -795,6 +883,16 @@ export default function AddSemanticViewModal({ } /> )} + {authentication && ( + { + setAuthentication(undefined); + authentication.retry(); + }} + /> + )} {/* Semantic Layer */}