diff --git a/plugins/ui/src/deephaven/ui/hooks/_transform.py b/plugins/ui/src/deephaven/ui/hooks/_transform.py new file mode 100644 index 000000000..9b498bd9a --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/_transform.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any, Optional, TypeVar, overload + +from deephaven.execution_context import get_exec_ctx +from deephaven.plugin_authorization import transform as _plugin_transform # type: ignore[import-not-found] + +T = TypeVar("T") + + +def _current_user_context() -> str: + """ + Return a string describing the current user's authorization context, for use in error messages. + + Returns: + A description of the current authorization context, or "unknown" if it cannot be determined. + """ + try: + return str(get_exec_ctx().j_exec_ctx.getAuthContext()) + except Exception: + return "unknown" + + +@overload +def transform(obj: None) -> None: + ... + + +@overload +def transform(obj: T) -> T: + ... + + +def transform(obj: Optional[Any]) -> Optional[Any]: + """ + Apply the server's authorization transform to ``obj`` for the current user's context. + + Unlike :func:`deephaven.plugin_authorization.transform`, this raises a clear error if the current user is not + permitted to access a non-``None`` object, rather than silently returning ``None`` (which would otherwise surface + later as a confusing ``AttributeError`` when the ``None`` result is used). + + Args: + obj: The object to transform (typically a table). ``None`` is returned unchanged. + + Returns: + The transformed object. + + Raises: + PermissionError: if the current user is not permitted to access ``obj``. + """ + if obj is None: + return None + result = _plugin_transform(obj) + if result is None: + raise PermissionError( + f"The current user ({_current_user_context()}) is not permitted to access the requested object." + ) + return result diff --git a/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py b/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py index 8c821d24a..7db9afa52 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py @@ -5,8 +5,9 @@ from deephaven.table import Table +from ._transform import transform from .use_memo import use_memo -from .use_table_data import first_column_table, use_table_data +from .use_table_data import first_column_table, _use_table_data_without_ticket_transform from ..types import Sentinel @@ -42,7 +43,9 @@ def use_cell_data(table: Table | None, sentinel: Sentinel = None) -> Any | Senti Any: The top left cell of the table. """ filtered_table = use_memo( - lambda: None if table is None else first_column_table(table).head(1), + lambda: None if table is None else first_column_table(transform(table)).head(1), [table], ) - return use_table_data(filtered_table, sentinel, _cell_data) + return _use_table_data_without_ticket_transform( + filtered_table, sentinel, _cell_data + ) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_column_data.py b/plugins/ui/src/deephaven/ui/hooks/use_column_data.py index c99a7d101..22b0cfe08 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_column_data.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_column_data.py @@ -4,8 +4,9 @@ from deephaven.table import Table +from ._transform import transform from .use_memo import use_memo -from .use_table_data import first_column_table, use_table_data +from .use_table_data import first_column_table, _use_table_data_without_ticket_transform from ..types import Sentinel, ColumnData @@ -43,7 +44,9 @@ def use_column_data( The first column of the table as a list or the sentinel value. """ filtered_table = use_memo( - lambda: None if table is None else first_column_table(table), + lambda: None if table is None else first_column_table(transform(table)), [table], ) - return use_table_data(filtered_table, sentinel, _column_data) + return _use_table_data_without_ticket_transform( + filtered_table, sentinel, _column_data + ) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_row_data.py b/plugins/ui/src/deephaven/ui/hooks/use_row_data.py index cad726198..e34a70aeb 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_row_data.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_row_data.py @@ -4,8 +4,9 @@ from deephaven.table import Table +from ._transform import transform from .use_memo import use_memo -from .use_table_data import use_table_data +from .use_table_data import _use_table_data_without_ticket_transform from ..types import Sentinel, RowData @@ -42,5 +43,7 @@ def use_row_data( Returns: The first row of the table as a dictionary or the sentinel value. """ - filtered_table = use_memo(lambda: None if table is None else table.head(1), [table]) - return use_table_data(filtered_table, sentinel, _row_data) + filtered_table = use_memo( + lambda: None if table is None else transform(table).head(1), [table] + ) + return _use_table_data_without_ticket_transform(filtered_table, sentinel, _row_data) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_row_list.py b/plugins/ui/src/deephaven/ui/hooks/use_row_list.py index 521a89e21..f32c5fa2d 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_row_list.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_row_list.py @@ -5,8 +5,9 @@ from deephaven.table import Table +from ._transform import transform from .use_memo import use_memo -from .use_table_data import use_table_data +from .use_table_data import _use_table_data_without_ticket_transform from ..types import Sentinel @@ -43,5 +44,7 @@ def use_row_list( Returns: The first row of the table as a list or the sentinel value. """ - filtered_table = use_memo(lambda: None if table is None else table.head(1), [table]) - return use_table_data(filtered_table, sentinel, _row_list) + filtered_table = use_memo( + lambda: None if table is None else transform(table).head(1), [table] + ) + return _use_table_data_without_ticket_transform(filtered_table, sentinel, _row_list) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_table_data.py b/plugins/ui/src/deephaven/ui/hooks/use_table_data.py index 9623a1cf4..9f4657d93 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_table_data.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_table_data.py @@ -12,10 +12,11 @@ from deephaven.server.executors import submit_task from deephaven.update_graph import has_exclusive_lock +from ._transform import transform as apply_ticket_transform from .use_callback import use_callback from .use_effect import use_effect from .use_state import use_state -from .use_table_listener import use_table_listener +from .use_table_listener import _use_table_listener_without_ticket_transform from ..types import Sentinel, TableData, TransformedData @@ -143,6 +144,34 @@ def use_table_data( Returns a dictionary with the contents of the table. Component will redraw if the table changes, resulting in an updated frame. + Args: + table: The table to listen to. If None, None will be returned, not the sentinel value. + sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. + transform: A function to transform the table data and is_sentinel values. Defaults to None, which will + return the data as TableData. + + Returns: + The table data or the sentinel value. + """ + table = apply_ticket_transform(table) + return _use_table_data_without_ticket_transform(table, sentinel, transform) + + +def _use_table_data_without_ticket_transform( + table: Table | None, + sentinel: Sentinel = None, + transform: ( + Callable[[pd.DataFrame | Sentinel | None, bool], TransformedData | Sentinel] + | None + ) = None, +) -> TableData | Sentinel | TransformedData: + """ + Returns a dictionary with the contents of the table. Component will redraw if the table + changes, resulting in an updated frame. + + Note that plugin transformations are not applied, so this function is intended only for use by other hooks that + have already applied a plugin transformation. + Args: table: The table to listen to. If None, None will be returned, not the sentinel value. sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. @@ -181,6 +210,6 @@ def use_table_data( ) # call table_updated every time the table updates - use_table_listener(table, listener, []) + _use_table_listener_without_ticket_transform(table, listener, []) return transform(data, is_sentinel) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py b/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py index cae15cf08..df7cadcc5 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py @@ -7,6 +7,7 @@ from deephaven.table_listener import listen, TableUpdate, TableListener from deephaven.execution_context import get_exec_ctx, ExecutionContext +from ._transform import transform from .use_effect import use_effect from ..types import LockType, Dependencies @@ -79,6 +80,39 @@ def use_table_listener( If any dependencies change, the listener will be recreated. In this case, updates may be missed if the table updates while the listener is being recreated. + Args: + table: The table to listen to. + listener: Either a function or a TableListener with an on_update function. + The function must take a TableUpdate and is_replay bool. + dependencies: Dependencies of the listener function, so the hook knows when to recreate the listener + description: An optional description for the UpdatePerformanceTracker to append to the listener’s + entry description, default is None. + do_replay: Whether to replay the initial snapshot of the table, default is False. + + Returns: + None + """ + transformed = transform(table) + return _use_table_listener_without_ticket_transform( + transformed, listener, dependencies, description, do_replay + ) + + +def _use_table_listener_without_ticket_transform( + table: Table | None, + listener: Callable[[TableUpdate, bool], None] | TableListener, + dependencies: Dependencies, + description: str | None = None, + do_replay: bool = False, +) -> None: + """ + Listen to a table and call a listener when the table updates. + If any dependencies change, the listener will be recreated. + In this case, updates may be missed if the table updates while the listener is being recreated. + + Note that plugin transformations are not applied, so this function is intended only for use by other hooks that + have already applied a plugin transformation. + Args: table: The table to listen to. listener: Either a function or a TableListener with an on_update function. diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementType.py b/plugins/ui/src/deephaven/ui/object_types/ElementType.py index 02d07ad8e..e4695a0da 100644 --- a/plugins/ui/src/deephaven/ui/object_types/ElementType.py +++ b/plugins/ui/src/deephaven/ui/object_types/ElementType.py @@ -1,9 +1,15 @@ -from typing import Any +from typing import Any, Literal from deephaven.plugin.object_type import BidirectionalObjectType, MessageStream from ..elements import Element from .ElementMessageStream import ElementMessageStream +# Escape-hatch configuration property. When set to true, deephaven.ui declares no authorization export behavior +# ("unset"), deferring to the server's default policy instead of enforcing the transform. +_DISABLE_AUTHORIZATION_EXPORT_TRANSFORM_PROPERTY = ( + "deephaven.ui.disableAuthorizationExportTransform" +) + class ElementType(BidirectionalObjectType): """ @@ -14,6 +20,30 @@ class ElementType(BidirectionalObjectType): def name(self) -> str: return "deephaven.ui.Element" + @property + def authorization_export_behavior(self) -> Literal["transform", "unset"]: + """Declares that deephaven.ui must export its references through the authorization transform. + + Server objects (tables, etc.) handed to the client via a deephaven.ui component are run through the + authorization transform in the viewer's context, so they carry the viewer's ACLs rather than the query + owner's. Setting the configuration property ``deephaven.ui.disableAuthorizationExportTransform`` to true + reverts to the server's default ("unset") behavior. If the configuration cannot be read, the transform is + enforced (fail secure). + """ + try: + # deephaven.configuration only exists in the 42.x server package; the import is conditional to + # maintain compatibility with 41.x, where ImportError is caught and the transform is enforced. + from deephaven.configuration import get_configuration # type: ignore[import-untyped,import-not-found] + + if get_configuration().get_bool( + _DISABLE_AUTHORIZATION_EXPORT_TRANSFORM_PROPERTY, False + ): + return "unset" + except Exception: + # Fail secure: if the escape hatch cannot be evaluated, enforce the transform. + pass + return "transform" + def is_type(self, obj: Any) -> bool: return isinstance(obj, Element)