-
Notifications
You must be signed in to change notification settings - Fork 13.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: Ability to share saved queries #24301
Changes from 9 commits
1eba12a
34e8b1f
65b259f
9c10e13
6c4fcc4
36052ed
eff8f24
11226f8
6e323eb
db575bd
2984e76
e4731dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,7 @@ export interface QueryEditor { | |
selectedText?: string; | ||
queryLimit?: number; | ||
description?: string; | ||
uuid: string | null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we use an optional string like the ones above? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding is that |
||
} | ||
|
||
export type toastState = { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -216,10 +216,10 @@ function SavedQueryList({ | |
}; | ||
|
||
const copyQueryLink = useCallback( | ||
(id: number) => { | ||
(uuid: string) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does TypeScript support a UUID type? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not that I'm aware of, didn't find anything on a quick google search either |
||
copyTextToClipboard(() => | ||
Promise.resolve( | ||
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`, | ||
`${window.location.origin}/superset/sqllab?savedQueryId=${uuid}`, | ||
), | ||
) | ||
.then(() => { | ||
|
@@ -390,7 +390,7 @@ function SavedQueryList({ | |
handleSavedQueryPreview(original.id); | ||
}; | ||
const handleEdit = () => openInSqlLab(original.id); | ||
const handleCopy = () => copyQueryLink(original.id); | ||
const handleCopy = () => copyQueryLink(original.uuid); | ||
const handleExport = () => handleBulkSavedQueryExport([original]); | ||
const handleDelete = () => setQueryCurrentlyDeleting(original); | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -22,7 +22,7 @@ | |||||
from zipfile import is_zipfile, ZipFile | ||||||
|
||||||
from flask import g, request, Response, send_file | ||||||
from flask_appbuilder.api import expose, protect, rison, safe | ||||||
from flask_appbuilder.api import expose, permission_name, protect, rison, safe | ||||||
from flask_appbuilder.models.sqla.interface import SQLAInterface | ||||||
from flask_babel import ngettext | ||||||
|
||||||
|
@@ -47,6 +47,7 @@ | |||||
from superset.queries.saved_queries.commands.importers.dispatcher import ( | ||||||
ImportSavedQueriesCommand, | ||||||
) | ||||||
from superset.queries.saved_queries.dao import SavedQueryDAO | ||||||
from superset.queries.saved_queries.filters import ( | ||||||
SavedQueryAllTextFilter, | ||||||
SavedQueryFavoriteFilter, | ||||||
|
@@ -57,6 +58,7 @@ | |||||
get_delete_ids_schema, | ||||||
get_export_ids_schema, | ||||||
openapi_spec_methods_override, | ||||||
SavedQueryGetSchema, | ||||||
) | ||||||
from superset.views.base_api import ( | ||||||
BaseSupersetModelRestApi, | ||||||
|
@@ -99,6 +101,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): | |||||
"sql", | ||||||
"sql_tables", | ||||||
"template_parameters", | ||||||
"uuid", | ||||||
] | ||||||
list_columns = [ | ||||||
"changed_on_delta_humanized", | ||||||
|
@@ -118,6 +121,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): | |||||
"schema", | ||||||
"sql", | ||||||
"sql_tables", | ||||||
"uuid", | ||||||
] | ||||||
if is_feature_enabled("TAGGING_SYSTEM"): | ||||||
list_columns += ["tags.id", "tags.name", "tags.type"] | ||||||
|
@@ -129,6 +133,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): | |||||
"schema", | ||||||
"sql", | ||||||
"template_parameters", | ||||||
"extra_json", | ||||||
] | ||||||
edit_columns = add_columns | ||||||
order_columns = [ | ||||||
|
@@ -158,9 +163,12 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): | |||||
"get_delete_ids_schema": get_delete_ids_schema, | ||||||
"get_export_ids_schema": get_export_ids_schema, | ||||||
} | ||||||
openapi_spec_component_schemas = (SavedQueryGetSchema,) | ||||||
openapi_spec_tag = "Queries" | ||||||
openapi_spec_methods = openapi_spec_methods_override | ||||||
|
||||||
saved_query_get_schema = SavedQueryGetSchema() | ||||||
|
||||||
related_field_filters = { | ||||||
"database": "database_name", | ||||||
} | ||||||
|
@@ -174,6 +182,52 @@ def pre_add(self, item: SavedQuery) -> None: | |||||
def pre_update(self, item: SavedQuery) -> None: | ||||||
self.pre_add(item) | ||||||
|
||||||
@expose("/<_id>", methods=("GET",)) | ||||||
@protect() | ||||||
@safe | ||||||
@permission_name("get") | ||||||
@statsd_metrics | ||||||
@event_logger.log_this_with_context( | ||||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", | ||||||
log_to_statsd=False, | ||||||
) | ||||||
def get(self, _id: str, **kwargs: Any) -> Response: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The convention is to add (rather than prepend) an underscore to variable names which conflict with the global namespace. Additionally can't the ID be an integer (rather than a string) or UUID. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the string type captures both integer id's and UUIDs. The dao will then cast the type appropriately depending on which is passed |
||||||
"""Get a saved query by id or uuid | ||||||
--- | ||||||
get: | ||||||
summary: >- | ||||||
Gets a saved query | ||||||
description: >- | ||||||
Users who did not create a saved query can only discover it via UUID | ||||||
parameters: | ||||||
- in: path | ||||||
name: _id | ||||||
schema: | ||||||
type: string | ||||||
description: ID or UUID of the saved query | ||||||
responses: | ||||||
200: | ||||||
description: Saved query | ||||||
content: | ||||||
application/json: | ||||||
schema: | ||||||
type: object | ||||||
properties: | ||||||
result: | ||||||
$ref: '#/components/schemas/SavedQueryGetSchema' | ||||||
401: | ||||||
$ref: '#/components/responses/401' | ||||||
404: | ||||||
$ref: '#/components/responses/404' | ||||||
500: | ||||||
$ref: '#/components/responses/500' | ||||||
""" | ||||||
saved_query = SavedQueryDAO.get_by_id(_id) | ||||||
if not saved_query: | ||||||
return self.response_404() | ||||||
result = self.saved_query_get_schema.dump(saved_query) | ||||||
return self.response(200, result=result) | ||||||
|
||||||
@expose("/", methods=("DELETE",)) | ||||||
@protect() | ||||||
@safe | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,15 +15,18 @@ | |
# specific language governing permissions and limitations | ||
# under the License. | ||
import logging | ||
import uuid | ||
from typing import Optional | ||
|
||
from flask_appbuilder.models.sqla.interface import SQLAInterface | ||
from sqlalchemy.exc import SQLAlchemyError | ||
|
||
from superset.dao.base import BaseDAO | ||
from superset.dao.exceptions import DAODeleteFailedError | ||
from superset.extensions import db | ||
from superset.models.sql_lab import SavedQuery | ||
from superset.queries.saved_queries.filters import SavedQueryFilter | ||
from superset.utils.core import is_uuid | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
@@ -44,3 +47,20 @@ def bulk_delete(models: Optional[list[SavedQuery]], commit: bool = True) -> None | |
except SQLAlchemyError as ex: | ||
db.session.rollback() | ||
raise DAODeleteFailedError() from ex | ||
|
||
@classmethod | ||
def get_by_id(cls, _id: str) -> Optional[SavedQuery]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't believe this is the right approach, i.e., per this code block the UUID option is bypass the security check (by way of the filter on line #63). The correct approach would be to generate a permalink similar to charts and dashboards. cc @michael-s-molina and @villebro as we've discussed this previously. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bypassing the security check if a UUID is passed is intentional, it allows users to access saved queries via the API only if the UUID link has been shared with them. There is precedence for this approach in the codebase, see the dashboard DAO: https://github.com/apache/superset/blob/master/superset/dashboards/dao.py#L47 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would use the permalink logic for this purpose and probably migrate the dashboard DAO too. Keeping multiple strategies for sharing resources only increases code complexity and opens the door for inconsistent security checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @michael-s-molina or @john-bodley given the discussion in the all hands meetup, did we come to a decision on whether we can move towards UUIDs here and save the permalink feature for a moment in time state that you want to share? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @eschutho I’m not sure that the UUID seems correct both from a security and consistency perspective. It feels like an internal representation that we’re now exposing to circumvent security, i.e., it feels akin to saying you enter a bar and have to present your ID (an integer or slug) for verification, however if you merely wave a UUID you bypass the bouncer. Granted it’s harder to forge a UUID as opposed to guess a valid ID number, but that still doesn’t seem secure. In my opinion the precedence which was set previously (the example @jfrag1 linked to—which is the same one I was referring to in the town hall) should be thought of as the exception rather than the rule, i.e., that logic would also need to be updated in a future PR. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with @michael-s-molina's comment regarding making this a SIP. I understand that this feels somewhat like a blocker to what feels like a somewhat benign fix, but I think it's important that we flush out (and detangle) the key concepts that Michael and myself raised to set the right precedence, as opposed to a bandaid solution. Historically UUIDs were obfuscated and solely used to make charts, dashboards, etc. globally unique across shared Superset instances, however their use within the API is more of an organic phenomenon and thus I think there's merit in pausing, taking a step back, and evaluating how we think about UUIDs, shareable links, etc. in a more holistic manner. I agree that finding a matching UUID is near impossible, but using UUIDs for security (via cryptography) isn't valid. Note this is in no way a criticism of your work, but more with the state of the Superset code base. At Airbnb we support thousands of weekly active users and are constantly fixing issues related to problematic features and/or inconsistent/non-coherent/complex functionality (you can see the vast majority of my commits to this repo are actually fixes and/or mechanism to improve quality). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agreed with using permalink as long term solution for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree that a SIP on this functionality is a good idea. We were planning to write one up, I think @john-bodley is also interested in writing it. If we think this PR is too premature at this point, what do you all think about us reverting this one line back to the old api so that we can fix the sharing capabilities that are currently broken, and then we can talk about a longer-term solution in an upcoming SIP? cc @hughhhh who is also helping on this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @zephyring, @jfrag1, @eschutho, @michael-s-molina, et al. I'm supportive of "reverting" the original PR to get us back to square one and then with said SIP we can proceed. I suspect the old API endpoint is a legacy FAB REST API which was slated for deletion in 2.3.X (we're now using FAB 4.3.2+). If the one line revert to the old API isn't via I think the alternative is something akin to what we do at Airbnb, where our PR description states:
Thus I think an alternative to changing the API endpoint would be to simply replace these lines with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. opened #24434 to revert the original change which broke this functionality |
||
if is_uuid(_id): | ||
return ( | ||
db.session.query(SavedQuery) | ||
.filter(SavedQuery.uuid == uuid.UUID(_id)) | ||
.scalar() | ||
) | ||
try: | ||
query = db.session.query(SavedQuery).filter(SavedQuery.id == int(_id)) | ||
except ValueError: # Invalid id | ||
return None | ||
query = cls.base_filter("id", SQLAInterface(SavedQuery, db.session)).apply( | ||
query, None | ||
) | ||
return query.scalar() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a concern that by switching to UUID existing URLs will break?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Existing URLs will still work just fine as the get saved query endpoint still accepts numerical id's