Skip to content

Commit 1fbbb71

Browse files
authored
feat: DH-22536: Apply transformations to dh.ui widgets when sending to clients. (#1357)
This is dependent on https://github.com/deephaven/deephaven-core/pull/8018/changes; and intended to go with it so that dh.ui indicates it does need to transform tables/objects sent to the user.
1 parent 98fa16e commit 1fbbb71

10 files changed

Lines changed: 230 additions & 22 deletions

File tree

plugins/ui/setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ package_dir=
2525
=src
2626
packages=find_namespace:
2727
install_requires =
28-
deephaven-core>=0.39.6
29-
deephaven-plugin>=0.6.0
28+
deephaven-core>=42.0
29+
deephaven-plugin>=0.7.0
3030
json-rpc~=1.15.0
3131
pyjsonpatch~=0.1.3
3232
deephaven-plugin-utilities>=0.0.2
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Optional, TypeVar, overload
4+
5+
from deephaven.execution_context import get_exec_ctx
6+
from deephaven.plugin_authorization import transform as _plugin_transform # type: ignore[import-not-found]
7+
from ..object_types.ElementType import _DISABLE_AUTHORIZATION_EXPORT_TRANSFORM_PROPERTY
8+
9+
T = TypeVar("T")
10+
11+
"""
12+
If we are disabling transforms, then we should not perform any transformation on the ingress to our hooks either. This
13+
may have the effect of allowing users to exfiltrate data; but is an escape hatch to avoid breaking existing dh.ui
14+
queries that depend on old behavior.
15+
"""
16+
skip_transform: bool
17+
try:
18+
# deephaven.configuration only exists in the 42.x server package; the import is conditional to
19+
# maintain compatibility with 41.x, where ImportError is caught and the transform is enforced.
20+
from deephaven.configuration import get_configuration # type: ignore[import-untyped,import-not-found]
21+
22+
skip_transform = get_configuration().get_bool(
23+
_DISABLE_AUTHORIZATION_EXPORT_TRANSFORM_PROPERTY, False
24+
)
25+
except Exception:
26+
skip_transform = False
27+
28+
29+
def _current_user_context() -> str:
30+
"""
31+
Return a string describing the current user's authorization context, for use in error messages.
32+
33+
Returns:
34+
A description of the current authorization context, or "unknown" if it cannot be determined.
35+
"""
36+
try:
37+
return str(get_exec_ctx().j_exec_ctx.getAuthContext())
38+
except Exception:
39+
return "unknown"
40+
41+
42+
@overload
43+
def transform(obj: None) -> None:
44+
...
45+
46+
47+
@overload
48+
def transform(obj: T) -> T:
49+
...
50+
51+
52+
def transform(obj: Optional[Any]) -> Optional[Any]:
53+
"""
54+
Apply the server's authorization transform to ``obj`` for the current user's context.
55+
56+
Unlike :func:`deephaven.plugin_authorization.transform`, this raises a clear error if the current user is not
57+
permitted to access a non-``None`` object, rather than silently returning ``None`` (which would otherwise surface
58+
later as a confusing ``AttributeError`` when the ``None`` result is used).
59+
60+
Args:
61+
obj: The object to transform (typically a table). ``None`` is returned unchanged.
62+
63+
Returns:
64+
The transformed object.
65+
66+
Raises:
67+
PermissionError: if the current user is not permitted to access ``obj``.
68+
"""
69+
if skip_transform:
70+
return obj
71+
if obj is None:
72+
return None
73+
result = _plugin_transform(obj)
74+
if result is None:
75+
raise PermissionError(
76+
f"The current user ({_current_user_context()}) is not permitted to access the requested object."
77+
)
78+
return result

plugins/ui/src/deephaven/ui/hooks/use_cell_data.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
from deephaven.table import Table
77

8+
from ._transform import transform
89
from .use_memo import use_memo
9-
from .use_table_data import first_column_table, use_table_data
10+
from .use_table_data import first_column_table, _use_table_data_without_ticket_transform
1011
from ..types import Sentinel
1112

1213

@@ -42,7 +43,9 @@ def use_cell_data(table: Table | None, sentinel: Sentinel = None) -> Any | Senti
4243
Any: The top left cell of the table.
4344
"""
4445
filtered_table = use_memo(
45-
lambda: None if table is None else first_column_table(table).head(1),
46+
lambda: None if table is None else first_column_table(transform(table)).head(1),
4647
[table],
4748
)
48-
return use_table_data(filtered_table, sentinel, _cell_data)
49+
return _use_table_data_without_ticket_transform(
50+
filtered_table, sentinel, _cell_data
51+
)

plugins/ui/src/deephaven/ui/hooks/use_column_data.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
from deephaven.table import Table
66

7+
from ._transform import transform
78
from .use_memo import use_memo
8-
from .use_table_data import first_column_table, use_table_data
9+
from .use_table_data import first_column_table, _use_table_data_without_ticket_transform
910
from ..types import Sentinel, ColumnData
1011

1112

@@ -43,7 +44,9 @@ def use_column_data(
4344
The first column of the table as a list or the sentinel value.
4445
"""
4546
filtered_table = use_memo(
46-
lambda: None if table is None else first_column_table(table),
47+
lambda: None if table is None else first_column_table(transform(table)),
4748
[table],
4849
)
49-
return use_table_data(filtered_table, sentinel, _column_data)
50+
return _use_table_data_without_ticket_transform(
51+
filtered_table, sentinel, _column_data
52+
)

plugins/ui/src/deephaven/ui/hooks/use_row_data.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
from deephaven.table import Table
66

7+
from ._transform import transform
78
from .use_memo import use_memo
8-
from .use_table_data import use_table_data
9+
from .use_table_data import _use_table_data_without_ticket_transform
910
from ..types import Sentinel, RowData
1011

1112

@@ -42,5 +43,7 @@ def use_row_data(
4243
Returns:
4344
The first row of the table as a dictionary or the sentinel value.
4445
"""
45-
filtered_table = use_memo(lambda: None if table is None else table.head(1), [table])
46-
return use_table_data(filtered_table, sentinel, _row_data)
46+
filtered_table = use_memo(
47+
lambda: None if table is None else transform(table).head(1), [table]
48+
)
49+
return _use_table_data_without_ticket_transform(filtered_table, sentinel, _row_data)

plugins/ui/src/deephaven/ui/hooks/use_row_list.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
from deephaven.table import Table
77

8+
from ._transform import transform
89
from .use_memo import use_memo
9-
from .use_table_data import use_table_data
10+
from .use_table_data import _use_table_data_without_ticket_transform
1011
from ..types import Sentinel
1112

1213

@@ -43,5 +44,7 @@ def use_row_list(
4344
Returns:
4445
The first row of the table as a list or the sentinel value.
4546
"""
46-
filtered_table = use_memo(lambda: None if table is None else table.head(1), [table])
47-
return use_table_data(filtered_table, sentinel, _row_list)
47+
filtered_table = use_memo(
48+
lambda: None if table is None else transform(table).head(1), [table]
49+
)
50+
return _use_table_data_without_ticket_transform(filtered_table, sentinel, _row_list)

plugins/ui/src/deephaven/ui/hooks/use_table_data.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
from deephaven.server.executors import submit_task
1313
from deephaven.update_graph import has_exclusive_lock
1414

15+
from ._transform import transform as apply_ticket_transform
1516
from .use_callback import use_callback
1617
from .use_effect import use_effect
1718
from .use_state import use_state
18-
from .use_table_listener import use_table_listener
19+
from .use_table_listener import _use_table_listener_without_ticket_transform
1920

2021
from ..types import Sentinel, TableData, TransformedData
2122

@@ -143,6 +144,34 @@ def use_table_data(
143144
Returns a dictionary with the contents of the table. Component will redraw if the table
144145
changes, resulting in an updated frame.
145146
147+
Args:
148+
table: The table to listen to. If None, None will be returned, not the sentinel value.
149+
sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None.
150+
transform: A function to transform the table data and is_sentinel values. Defaults to None, which will
151+
return the data as TableData.
152+
153+
Returns:
154+
The table data or the sentinel value.
155+
"""
156+
table = apply_ticket_transform(table)
157+
return _use_table_data_without_ticket_transform(table, sentinel, transform)
158+
159+
160+
def _use_table_data_without_ticket_transform(
161+
table: Table | None,
162+
sentinel: Sentinel = None,
163+
transform: (
164+
Callable[[pd.DataFrame | Sentinel | None, bool], TransformedData | Sentinel]
165+
| None
166+
) = None,
167+
) -> TableData | Sentinel | TransformedData:
168+
"""
169+
Returns a dictionary with the contents of the table. Component will redraw if the table
170+
changes, resulting in an updated frame.
171+
172+
Note that plugin transformations are not applied, so this function is intended only for use by other hooks that
173+
have already applied a plugin transformation.
174+
146175
Args:
147176
table: The table to listen to. If None, None will be returned, not the sentinel value.
148177
sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None.
@@ -181,6 +210,6 @@ def use_table_data(
181210
)
182211

183212
# call table_updated every time the table updates
184-
use_table_listener(table, listener, [])
213+
_use_table_listener_without_ticket_transform(table, listener, [])
185214

186215
return transform(data, is_sentinel)

plugins/ui/src/deephaven/ui/hooks/use_table_listener.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from deephaven.table_listener import listen, TableUpdate, TableListener
88
from deephaven.execution_context import get_exec_ctx, ExecutionContext
99

10+
from ._transform import transform
1011
from .use_effect import use_effect
1112
from ..types import LockType, Dependencies
1213

@@ -79,6 +80,39 @@ def use_table_listener(
7980
If any dependencies change, the listener will be recreated.
8081
In this case, updates may be missed if the table updates while the listener is being recreated.
8182
83+
Args:
84+
table: The table to listen to.
85+
listener: Either a function or a TableListener with an on_update function.
86+
The function must take a TableUpdate and is_replay bool.
87+
dependencies: Dependencies of the listener function, so the hook knows when to recreate the listener
88+
description: An optional description for the UpdatePerformanceTracker to append to the listener’s
89+
entry description, default is None.
90+
do_replay: Whether to replay the initial snapshot of the table, default is False.
91+
92+
Returns:
93+
None
94+
"""
95+
transformed = transform(table)
96+
return _use_table_listener_without_ticket_transform(
97+
transformed, listener, dependencies, description, do_replay
98+
)
99+
100+
101+
def _use_table_listener_without_ticket_transform(
102+
table: Table | None,
103+
listener: Callable[[TableUpdate, bool], None] | TableListener,
104+
dependencies: Dependencies,
105+
description: str | None = None,
106+
do_replay: bool = False,
107+
) -> None:
108+
"""
109+
Listen to a table and call a listener when the table updates.
110+
If any dependencies change, the listener will be recreated.
111+
In this case, updates may be missed if the table updates while the listener is being recreated.
112+
113+
Note that plugin transformations are not applied, so this function is intended only for use by other hooks that
114+
have already applied a plugin transformation.
115+
82116
Args:
83117
table: The table to listen to.
84118
listener: Either a function or a TableListener with an on_update function.

plugins/ui/src/deephaven/ui/object_types/ElementType.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
from typing import Any
1+
from typing import Any, Literal
22

33
from deephaven.plugin.object_type import BidirectionalObjectType, MessageStream
44
from ..elements import Element
55
from .ElementMessageStream import ElementMessageStream
66

7+
# Escape-hatch configuration property. When set to true, deephaven.ui declares no authorization export behavior
8+
# ("unset"), deferring to the server's default policy instead of enforcing the transform.
9+
_DISABLE_AUTHORIZATION_EXPORT_TRANSFORM_PROPERTY = (
10+
"deephaven.ui.disableAuthorizationExportTransform"
11+
)
12+
713

814
class ElementType(BidirectionalObjectType):
915
"""
@@ -14,6 +20,30 @@ class ElementType(BidirectionalObjectType):
1420
def name(self) -> str:
1521
return "deephaven.ui.Element"
1622

23+
@property
24+
def authorization_export_behavior(self) -> Literal["transform", "unset"]:
25+
"""Declares that deephaven.ui must export its references through the authorization transform.
26+
27+
Server objects (tables, etc.) handed to the client via a deephaven.ui component are run through the
28+
authorization transform in the viewer's context, so they carry the viewer's ACLs rather than the query
29+
owner's. Setting the configuration property ``deephaven.ui.disableAuthorizationExportTransform`` to true
30+
reverts to the server's default ("unset") behavior. If the configuration cannot be read, the transform is
31+
enforced (fail secure).
32+
"""
33+
try:
34+
# deephaven.configuration only exists in the 42.x server package; the import is conditional to
35+
# maintain compatibility with 41.x, where ImportError is caught and the transform is enforced.
36+
from deephaven.configuration import get_configuration # type: ignore[import-untyped,import-not-found]
37+
38+
if get_configuration().get_bool(
39+
_DISABLE_AUTHORIZATION_EXPORT_TRANSFORM_PROPERTY, False
40+
):
41+
return "unset"
42+
except Exception:
43+
# Fail secure: if the escape hatch cannot be evaluated, enforce the transform.
44+
pass
45+
return "transform"
46+
1747
def is_type(self, obj: Any) -> bool:
1848
return isinstance(obj, Element)
1949

0 commit comments

Comments
 (0)