Flowfile 0.5.3__py3-none-any.whl → 0.5.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowfile/__init__.py +16 -0
- flowfile/__main__.py +94 -1
- flowfile/web/static/assets/{AdminView-49392a9a.js → AdminView-c2c7942b.js} +1 -1
- flowfile/web/static/assets/{CloudConnectionView-f13f202b.js → CloudConnectionView-7a3042c6.js} +4 -4
- flowfile/web/static/assets/{CloudConnectionView-36bcd6df.css → CloudConnectionView-cf85f943.css} +17 -17
- flowfile/web/static/assets/{CloudStorageReader-0023d4a5.js → CloudStorageReader-709c4037.js} +8 -8
- flowfile/web/static/assets/{CloudStorageWriter-8e781e11.js → CloudStorageWriter-604c51a8.js} +8 -8
- flowfile/web/static/assets/ColumnActionInput-c44b7aee.css +159 -0
- flowfile/web/static/assets/ColumnActionInput-d63d6746.js +330 -0
- flowfile/web/static/assets/{ColumnSelector-8ad68ea9.js → ColumnSelector-0c8cd1cd.js} +1 -1
- flowfile/web/static/assets/ContextMenu-366bf1b4.js +9 -0
- flowfile/web/static/assets/ContextMenu-85cf5b44.js +9 -0
- flowfile/web/static/assets/ContextMenu-9d28ae6d.js +9 -0
- flowfile/web/static/assets/ContextMenu.vue_vue_type_script_setup_true_lang-774c517c.js +59 -0
- flowfile/web/static/assets/{CrossJoin-03df6938.js → CrossJoin-38e5b99a.js} +9 -9
- flowfile/web/static/assets/{CustomNode-8479239b.js → CustomNode-76e8f3f5.js} +27 -20
- flowfile/web/static/assets/CustomNode-edb9b939.css +42 -0
- flowfile/web/static/assets/{DatabaseConnectionSettings-869e3efd.js → DatabaseConnectionSettings-38155669.js} +4 -4
- flowfile/web/static/assets/{DatabaseConnectionSettings-e91df89a.css → DatabaseConnectionSettings-c20a1e16.css} +22 -20
- flowfile/web/static/assets/{DatabaseReader-c58b9552.js → DatabaseReader-2e549c8f.js} +13 -13
- flowfile/web/static/assets/{DatabaseReader-36898a00.css → DatabaseReader-5bf8c75b.css} +39 -44
- flowfile/web/static/assets/{DatabaseView-d26a9140.js → DatabaseView-dc877c29.js} +2 -2
- flowfile/web/static/assets/{DatabaseWriter-217a99f1.css → DatabaseWriter-bdcf2c8b.css} +27 -25
- flowfile/web/static/assets/{DatabaseWriter-4d05ddc7.js → DatabaseWriter-ffb91864.js} +12 -12
- flowfile/web/static/assets/{DesignerView-a6d0ee84.css → DesignerView-71d4e9a1.css} +429 -376
- flowfile/web/static/assets/{DesignerView-e6f5c0e8.js → DesignerView-a4466dab.js} +338 -183
- flowfile/web/static/assets/{DocumentationView-2e78ef1b.js → DocumentationView-979afc84.js} +3 -3
- flowfile/web/static/assets/{DocumentationView-fd46c656.css → DocumentationView-9ea6e871.css} +9 -9
- flowfile/web/static/assets/{ExploreData-7b54caca.js → ExploreData-e4b92aaf.js} +7 -7
- flowfile/web/static/assets/{ExternalSource-47ab05a3.css → ExternalSource-7ac7373f.css} +17 -17
- flowfile/web/static/assets/{ExternalSource-3fa399b2.js → ExternalSource-d08e7227.js} +9 -9
- flowfile/web/static/assets/{Filter-8cbbdbf3.js → Filter-7add806d.js} +9 -9
- flowfile/web/static/assets/{Formula-aac42b1e.js → Formula-36ab24d2.js} +9 -9
- flowfile/web/static/assets/{FuzzyMatch-cd9bbfca.js → FuzzyMatch-cc01bb04.js} +10 -10
- flowfile/web/static/assets/{GraphSolver-c24dec17.css → GraphSolver-4b4d7db9.css} +4 -4
- flowfile/web/static/assets/{GraphSolver-c7e6780e.js → GraphSolver-4fb98f3b.js} +11 -11
- flowfile/web/static/assets/GroupBy-5792782d.css +9 -0
- flowfile/web/static/assets/{GroupBy-93c5d22b.js → GroupBy-b3c8f429.js} +9 -9
- flowfile/web/static/assets/{Join-a19b2de2.js → Join-096b7b26.js} +10 -10
- flowfile/web/static/assets/{LoginView-0df4ed0a.js → LoginView-c33a246a.js} +1 -1
- flowfile/web/static/assets/{ManualInput-3702e677.css → ManualInput-39111f19.css} +48 -48
- flowfile/web/static/assets/{ManualInput-8d3374b2.js → ManualInput-7307e9b1.js} +55 -13
- flowfile/web/static/assets/{MultiSelect-ad1b6243.js → MultiSelect-14822c48.js} +2 -2
- flowfile/web/static/assets/{MultiSelect.vue_vue_type_script_setup_true_lang-e278950d.js → MultiSelect.vue_vue_type_script_setup_true_lang-90c4d340.js} +1 -1
- flowfile/web/static/assets/{NodeDesigner-40b647c9.js → NodeDesigner-5036c392.js} +171 -69
- flowfile/web/static/assets/{NodeDesigner-5f53be3f.css → NodeDesigner-94cd4dd3.css} +190 -190
- flowfile/web/static/assets/{NumericInput-7100234c.js → NumericInput-15cf3b72.js} +2 -2
- flowfile/web/static/assets/{NumericInput.vue_vue_type_script_setup_true_lang-5130219f.js → NumericInput.vue_vue_type_script_setup_true_lang-91e679d7.js} +1 -1
- flowfile/web/static/assets/{Output-f5efd2aa.js → Output-1f8ed42c.js} +13 -12
- flowfile/web/static/assets/{Output-35e97000.css → Output-692dd25d.css} +10 -10
- flowfile/web/static/assets/{Pivot-d981d23c.js → Pivot-0e153f4e.js} +10 -10
- flowfile/web/static/assets/{PivotValidation-63de1f73.js → PivotValidation-5a4f7c79.js} +1 -1
- flowfile/web/static/assets/{PivotValidation-39386e95.js → PivotValidation-81ec2a33.js} +1 -1
- flowfile/web/static/assets/{PolarsCode-f9d69217.js → PolarsCode-a39f15ac.js} +7 -7
- flowfile/web/static/assets/PopOver-ddcfe4f6.js +138 -0
- flowfile/web/static/assets/{Read-aec2e377.js → Read-39b63932.js} +15 -14
- flowfile/web/static/assets/{Read-36e7bd51.css → Read-90f366bc.css} +13 -13
- flowfile/web/static/assets/{RecordCount-78ed6845.js → RecordCount-e9048ccd.js} +6 -6
- flowfile/web/static/assets/{RecordId-2156e890.js → RecordId-ad02521d.js} +9 -9
- flowfile/web/static/assets/{SQLQueryComponent-48c72f5b.js → SQLQueryComponent-2eeecf0b.js} +3 -3
- flowfile/web/static/assets/SQLQueryComponent-edb90b98.css +29 -0
- flowfile/web/static/assets/{Sample-1352ca74.js → Sample-9a68c23d.js} +6 -6
- flowfile/web/static/assets/{SecretSelector-22b5ff89.js → SecretSelector-2429f35a.js} +2 -2
- flowfile/web/static/assets/{SecretsView-17df66ee.js → SecretsView-c6afc915.js} +2 -2
- flowfile/web/static/assets/{Select-0aee4c54.js → Select-fcd002b6.js} +9 -9
- flowfile/web/static/assets/{SettingsSection-cd341bb6.js → SettingsSection-5ce15962.js} +1 -1
- flowfile/web/static/assets/{SettingsSection-0784e157.js → SettingsSection-c6b1362c.js} +1 -1
- flowfile/web/static/assets/{SettingsSection-f2002a6d.js → SettingsSection-cebb91d5.js} +1 -1
- flowfile/web/static/assets/SetupView-2d12e01f.js +160 -0
- flowfile/web/static/assets/SetupView-ec26f76a.css +230 -0
- flowfile/web/static/assets/{SingleSelect-460cc0ea.js → SingleSelect-b67de4eb.js} +2 -2
- flowfile/web/static/assets/{SingleSelect.vue_vue_type_script_setup_true_lang-30741bb2.js → SingleSelect.vue_vue_type_script_setup_true_lang-eedb70eb.js} +1 -1
- flowfile/web/static/assets/{SliderInput-5d926864.js → SliderInput-fd8134ac.js} +1 -1
- flowfile/web/static/assets/Sort-4abb7fae.css +9 -0
- flowfile/web/static/assets/{Sort-3cdc971b.js → Sort-c005a573.js} +9 -9
- flowfile/web/static/assets/{TextInput-a2d0bfbd.js → TextInput-1bb31dab.js} +2 -2
- flowfile/web/static/assets/{TextInput.vue_vue_type_script_setup_true_lang-abad1ca2.js → TextInput.vue_vue_type_script_setup_true_lang-a51fe730.js} +1 -1
- flowfile/web/static/assets/{TextToRows-918945f7.js → TextToRows-4f363753.js} +9 -9
- flowfile/web/static/assets/{ToggleSwitch-f0ef5196.js → ToggleSwitch-ca0f2e5e.js} +2 -2
- flowfile/web/static/assets/{ToggleSwitch.vue_vue_type_script_setup_true_lang-5605c793.js → ToggleSwitch.vue_vue_type_script_setup_true_lang-49aa41d8.js} +1 -1
- flowfile/web/static/assets/{UnavailableFields-54d2f518.css → UnavailableFields-394a1f78.css} +13 -13
- flowfile/web/static/assets/{UnavailableFields-bdad6144.js → UnavailableFields-f6147968.js} +4 -4
- flowfile/web/static/assets/{Union-e8ab8c86.js → Union-c65f17b7.js} +6 -6
- flowfile/web/static/assets/Unique-2b705521.css +3 -0
- flowfile/web/static/assets/{Unique-8cd4f976.js → Unique-a1d96fb2.js} +12 -12
- flowfile/web/static/assets/{Unpivot-710a2948.css → Unpivot-b6ad6427.css} +6 -6
- flowfile/web/static/assets/{Unpivot-8da14095.js → Unpivot-c2657ff3.js} +11 -11
- flowfile/web/static/assets/{UnpivotValidation-6f7d89ff.js → UnpivotValidation-28e29a3b.js} +1 -1
- flowfile/web/static/assets/{VueGraphicWalker-3fb312e1.js → VueGraphicWalker-2fc3ddd4.js} +1 -1
- flowfile/web/static/assets/{api-24483f0d.js → api-df48ec50.js} +1 -1
- flowfile/web/static/assets/{api-8b81fa73.js → api-ee542cf7.js} +1 -1
- flowfile/web/static/assets/{dropDown-3d8dc5fa.css → dropDown-1d6acbd9.css} +26 -26
- flowfile/web/static/assets/{dropDown-ac0fda9d.js → dropDown-7576a76a.js} +3 -3
- flowfile/web/static/assets/{fullEditor-5497a84a.js → fullEditor-7583bef5.js} +3 -3
- flowfile/web/static/assets/{fullEditor-a0be62b3.css → fullEditor-fe9f7e18.css} +3 -3
- flowfile/web/static/assets/{genericNodeSettings-99014e1d.js → genericNodeSettings-0155288b.js} +2 -3
- flowfile/web/static/assets/{index-3ba44389.js → index-057d770d.js} +2 -2
- flowfile/web/static/assets/{index-07dda503.js → index-aeec439d.js} +1 -1
- flowfile/web/static/assets/{index-fb6493ae.js → index-ca6799de.js} +2293 -196
- flowfile/web/static/assets/{index-e6289dd0.css → index-d60c9dd4.css} +560 -10
- flowfile/web/static/assets/nodeInput-d478b9ac.js +2 -0
- flowfile/web/static/assets/{outputCsv-8f8ba42d.js → outputCsv-c492b15e.js} +3 -3
- flowfile/web/static/assets/outputCsv-cc84e09f.css +2499 -0
- flowfile/web/static/assets/{outputExcel-393f4fef.js → outputExcel-13bfa10f.js} +1 -1
- flowfile/web/static/assets/{outputParquet-07c81f65.js → outputParquet-9be1523a.js} +1 -1
- flowfile/web/static/assets/{readCsv-07f6d9ad.js → readCsv-5a49a8c9.js} +1 -1
- flowfile/web/static/assets/{readExcel-ed69bc8f.js → readExcel-27c30ad8.js} +3 -3
- flowfile/web/static/assets/{readParquet-e3ed4528.js → readParquet-446bde68.js} +1 -1
- flowfile/web/static/assets/{secrets.api-002e7d7e.js → secrets.api-34431884.js} +1 -1
- flowfile/web/static/assets/{selectDynamic-80b92899.js → selectDynamic-5754a2b1.js} +2 -3
- flowfile/web/static/assets/{vue-codemirror.esm-0965f39f.js → vue-codemirror.esm-8f46fb36.js} +1 -1
- flowfile/web/static/assets/{vue-content-loader.es-c506ad97.js → vue-content-loader.es-808fe33a.js} +1 -1
- flowfile/web/static/index.html +2 -2
- {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/METADATA +2 -2
- {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/RECORD +139 -134
- flowfile_core/auth/secrets.py +56 -13
- flowfile_core/fileExplorer/funcs.py +26 -4
- flowfile_core/flowfile/code_generator/__init__.py +11 -0
- flowfile_core/flowfile/code_generator/code_generator.py +347 -2
- flowfile_core/flowfile/flow_data_engine/flow_data_engine.py +13 -1
- flowfile_core/flowfile/flow_data_engine/subprocess_operations/subprocess_operations.py +12 -0
- flowfile_core/flowfile/flow_graph.py +2 -0
- flowfile_core/flowfile/flow_node/flow_node.py +52 -28
- flowfile_core/flowfile/node_designer/__init__.py +4 -0
- flowfile_core/flowfile/node_designer/ui_components.py +144 -1
- flowfile_core/main.py +2 -4
- flowfile_core/routes/public.py +43 -1
- flowfile_core/schemas/cloud_storage_schemas.py +39 -15
- flowfile_core/secret_manager/secret_manager.py +107 -6
- flowfile_frame/__init__.py +11 -0
- flowfile_frame/database/__init__.py +36 -0
- flowfile_frame/database/connection_manager.py +205 -0
- flowfile_frame/database/frame_helpers.py +249 -0
- flowfile_worker/configs.py +31 -15
- flowfile_worker/secrets.py +105 -15
- flowfile_worker/spawner.py +10 -6
- flowfile/web/static/assets/ContextMenu-26d4dd27.css +0 -26
- flowfile/web/static/assets/ContextMenu-31ee57f0.js +0 -41
- flowfile/web/static/assets/ContextMenu-69a74055.js +0 -41
- flowfile/web/static/assets/ContextMenu-8e2051c6.js +0 -41
- flowfile/web/static/assets/ContextMenu-8ec1729e.css +0 -26
- flowfile/web/static/assets/ContextMenu-9b310c60.css +0 -26
- flowfile/web/static/assets/CustomNode-59e99a86.css +0 -32
- flowfile/web/static/assets/GroupBy-be7ac0bf.css +0 -51
- flowfile/web/static/assets/PopOver-b22f049e.js +0 -939
- flowfile/web/static/assets/SQLQueryComponent-1c2f26b4.css +0 -27
- flowfile/web/static/assets/Sort-8a871341.css +0 -51
- flowfile/web/static/assets/Unique-9fb2f567.css +0 -51
- flowfile/web/static/assets/nodeInput-0eb13f1a.js +0 -2
- flowfile/web/static/assets/outputCsv-b9a072af.css +0 -2499
- {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/WHEEL +0 -0
- {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/entry_points.txt +0 -0
- {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import threading
|
|
1
2
|
from collections.abc import Callable, Generator
|
|
2
3
|
from time import sleep
|
|
3
4
|
from typing import Any, Literal, Optional
|
|
@@ -128,6 +129,7 @@ class FlowNode:
|
|
|
128
129
|
self._cache_progress = None
|
|
129
130
|
self._schema_callback = None
|
|
130
131
|
self._state_needs_reset = False
|
|
132
|
+
self._execution_lock = threading.RLock() # Protects concurrent access to get_resulting_data
|
|
131
133
|
|
|
132
134
|
@property
|
|
133
135
|
def state_needs_reset(self) -> bool:
|
|
@@ -147,10 +149,11 @@ class FlowNode:
|
|
|
147
149
|
"""
|
|
148
150
|
self._state_needs_reset = v
|
|
149
151
|
|
|
150
|
-
|
|
151
|
-
def create_schema_callback_from_function(f: Callable) -> Callable[[], list[FlowfileColumn]]:
|
|
152
|
+
def create_schema_callback_from_function(self, f: Callable) -> Callable[[], list[FlowfileColumn]]:
|
|
152
153
|
"""Wraps a node's function to create a schema callback that extracts the schema.
|
|
153
154
|
|
|
155
|
+
Thread-safe: uses _execution_lock to prevent concurrent execution with get_resulting_data.
|
|
156
|
+
|
|
154
157
|
Args:
|
|
155
158
|
f: The node's core function that returns a FlowDataEngine instance.
|
|
156
159
|
|
|
@@ -161,7 +164,8 @@ class FlowNode:
|
|
|
161
164
|
def schema_callback() -> list[FlowfileColumn]:
|
|
162
165
|
try:
|
|
163
166
|
logger.info("Executing the schema callback function based on the node function")
|
|
164
|
-
|
|
167
|
+
with self._execution_lock:
|
|
168
|
+
return f().schema
|
|
165
169
|
except Exception as e:
|
|
166
170
|
logger.warning(f"Error with the schema callback: {e}")
|
|
167
171
|
return []
|
|
@@ -575,6 +579,7 @@ class FlowNode:
|
|
|
575
579
|
"""Executes the node's function to produce the actual output data.
|
|
576
580
|
|
|
577
581
|
Handles both regular functions and external data sources.
|
|
582
|
+
Thread-safe: uses _execution_lock to prevent concurrent execution.
|
|
578
583
|
|
|
579
584
|
Returns:
|
|
580
585
|
A FlowDataEngine instance containing the result, or None on error.
|
|
@@ -583,30 +588,40 @@ class FlowNode:
|
|
|
583
588
|
Exception: Propagates exceptions from the node's function execution.
|
|
584
589
|
"""
|
|
585
590
|
if self.is_setup:
|
|
586
|
-
|
|
587
|
-
self.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
591
|
+
with self._execution_lock:
|
|
592
|
+
if self.results.resulting_data is None and self.results.errors is None:
|
|
593
|
+
self.print("getting resulting data")
|
|
594
|
+
try:
|
|
595
|
+
if isinstance(self.function, FlowDataEngine):
|
|
596
|
+
fl: FlowDataEngine = self.function
|
|
597
|
+
elif self.node_type == "external_source":
|
|
598
|
+
fl: FlowDataEngine = self.function()
|
|
599
|
+
fl.collect_external()
|
|
600
|
+
self.node_settings.streamable = False
|
|
601
|
+
else:
|
|
602
|
+
try:
|
|
603
|
+
self.print("Collecting input data from all inputs")
|
|
604
|
+
input_data = []
|
|
605
|
+
for i, v in enumerate(self.all_inputs):
|
|
606
|
+
self.print(f"Getting resulting data from input {i} (node {v.node_id})")
|
|
607
|
+
input_result = v.get_resulting_data()
|
|
608
|
+
self.print(f"Input {i} data type: {type(input_result)}, dataframe type: {type(input_result.data_frame) if input_result else 'None'}")
|
|
609
|
+
input_data.append(input_result)
|
|
610
|
+
self.print(f"All {len(input_data)} inputs collected, calling node function")
|
|
611
|
+
fl = self._function(*input_data)
|
|
612
|
+
self.print(f"Node function returned, result type: {type(fl)}")
|
|
613
|
+
except Exception as e:
|
|
614
|
+
raise e
|
|
615
|
+
fl.set_streamable(self.node_settings.streamable)
|
|
616
|
+
self.results.resulting_data = fl
|
|
617
|
+
self.node_schema.result_schema = fl.schema
|
|
618
|
+
except Exception as e:
|
|
619
|
+
self.results.resulting_data = FlowDataEngine()
|
|
620
|
+
self.results.errors = str(e)
|
|
621
|
+
self.node_stats.has_run_with_current_setup = False
|
|
622
|
+
self.node_stats.has_completed_last_run = False
|
|
623
|
+
raise e
|
|
624
|
+
return self.results.resulting_data
|
|
610
625
|
|
|
611
626
|
def _predicted_data_getter(self) -> FlowDataEngine | None:
|
|
612
627
|
"""Internal helper to get a predicted data result.
|
|
@@ -844,11 +859,18 @@ class FlowNode:
|
|
|
844
859
|
self.results.resulting_data = self.get_resulting_data()
|
|
845
860
|
self.node_stats.has_run_with_current_setup = True
|
|
846
861
|
return
|
|
862
|
+
|
|
847
863
|
try:
|
|
848
|
-
self.get_resulting_data()
|
|
864
|
+
result_data = self.get_resulting_data()
|
|
865
|
+
# Use 'is not None' instead of truthiness check to avoid triggering __len__()
|
|
866
|
+
# which calls .collect() on the LazyFrame and can cause issues
|
|
867
|
+
if result_data is None:
|
|
868
|
+
self.results.errors = "Error with creating the lazy frame, most likely due to invalid graph"
|
|
869
|
+
raise Exception("get_resulting_data returned None")
|
|
849
870
|
except Exception as e:
|
|
850
871
|
self.results.errors = "Error with creating the lazy frame, most likely due to invalid graph"
|
|
851
872
|
raise e
|
|
873
|
+
|
|
852
874
|
if not performance_mode:
|
|
853
875
|
external_df_fetcher = ExternalDfFetcher(
|
|
854
876
|
lf=self.get_resulting_data().data_frame,
|
|
@@ -858,6 +880,7 @@ class FlowNode:
|
|
|
858
880
|
node_id=self.node_id,
|
|
859
881
|
)
|
|
860
882
|
self._fetch_cached_df = external_df_fetcher
|
|
883
|
+
|
|
861
884
|
try:
|
|
862
885
|
lf = external_df_fetcher.get_result()
|
|
863
886
|
self.results.resulting_data = FlowDataEngine(
|
|
@@ -869,6 +892,7 @@ class FlowNode:
|
|
|
869
892
|
node_id=self.node_id,
|
|
870
893
|
).result,
|
|
871
894
|
)
|
|
895
|
+
|
|
872
896
|
if not performance_mode:
|
|
873
897
|
self.store_example_data_generator(external_df_fetcher)
|
|
874
898
|
self.node_stats.has_run_with_current_setup = True
|
|
@@ -15,7 +15,9 @@ from .custom_node import CustomNodeBase, NodeSettings
|
|
|
15
15
|
|
|
16
16
|
# Import all UI components so they can be used directly
|
|
17
17
|
from .ui_components import (
|
|
18
|
+
ActionOption,
|
|
18
19
|
AvailableSecrets,
|
|
20
|
+
ColumnActionInput,
|
|
19
21
|
ColumnSelector,
|
|
20
22
|
IncomingColumns,
|
|
21
23
|
MultiSelect,
|
|
@@ -42,6 +44,8 @@ __all__ = [
|
|
|
42
44
|
"MultiSelect",
|
|
43
45
|
"NodeSettings",
|
|
44
46
|
"ColumnSelector",
|
|
47
|
+
"ColumnActionInput",
|
|
48
|
+
"ActionOption",
|
|
45
49
|
"IncomingColumns",
|
|
46
50
|
"AvailableSecrets",
|
|
47
51
|
"SecretSelector",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, Literal
|
|
1
|
+
from typing import Any, Literal, NamedTuple
|
|
2
2
|
|
|
3
3
|
from pydantic import BaseModel, Field, SecretStr, computed_field
|
|
4
4
|
|
|
@@ -11,6 +11,29 @@ from flowfile_core.types import DataType, TypeSpec
|
|
|
11
11
|
InputType = Literal["text", "number", "secret", "array", "date", "boolean"]
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class ActionOption(NamedTuple):
|
|
15
|
+
"""
|
|
16
|
+
A named tuple representing an action option with a value and display label.
|
|
17
|
+
|
|
18
|
+
Use this to define actions with custom labels in ColumnActionInput:
|
|
19
|
+
actions=[
|
|
20
|
+
ActionOption("sum", "Sum"),
|
|
21
|
+
ActionOption("avg", "Average"),
|
|
22
|
+
"count" # plain strings also work
|
|
23
|
+
]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
value: str
|
|
27
|
+
"""The internal value used in the data."""
|
|
28
|
+
|
|
29
|
+
label: str
|
|
30
|
+
"""The display label shown in the UI."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Type alias for action specifications - accepts strings or ActionOption tuples
|
|
34
|
+
ActionSpec = str | ActionOption
|
|
35
|
+
|
|
36
|
+
|
|
14
37
|
def normalize_input_to_data_types(v: Any) -> Literal["ALL"] | list[DataType]:
|
|
15
38
|
"""
|
|
16
39
|
Normalizes a wide variety of inputs to either 'ALL' or a sorted list of DataType enums.
|
|
@@ -376,3 +399,123 @@ class SecretSelector(FlowfileInComponent):
|
|
|
376
399
|
if self.name_prefix:
|
|
377
400
|
data["name_prefix"] = self.name_prefix
|
|
378
401
|
return data
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class ColumnActionInput(FlowfileInComponent):
|
|
405
|
+
"""
|
|
406
|
+
A generic UI component for configuring column-based transformations.
|
|
407
|
+
|
|
408
|
+
This component allows users to select columns, choose an action/transformation,
|
|
409
|
+
and optionally rename the output. It can be configured for many use cases:
|
|
410
|
+
rolling windows, aggregations, string transformations, type conversions, etc.
|
|
411
|
+
|
|
412
|
+
The component displays:
|
|
413
|
+
- A list of available columns (filterable by data type)
|
|
414
|
+
- A table of configured operations with: Column, Action, Output Name
|
|
415
|
+
- Optional group by and order by selectors
|
|
416
|
+
|
|
417
|
+
Example - Rolling Window:
|
|
418
|
+
ColumnActionInput(
|
|
419
|
+
label="Rolling Calculations",
|
|
420
|
+
actions=["sum", "mean", "min", "max"],
|
|
421
|
+
output_name_template="{column}_rolling_{action}",
|
|
422
|
+
show_group_by=True,
|
|
423
|
+
show_order_by=True,
|
|
424
|
+
data_types="Numeric"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
Example - String Transformations:
|
|
428
|
+
ColumnActionInput(
|
|
429
|
+
label="String Operations",
|
|
430
|
+
actions=["upper", "lower", "trim", "reverse"],
|
|
431
|
+
output_name_template="{column}_{action}",
|
|
432
|
+
data_types="String"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
Example - Aggregations with ActionOption:
|
|
436
|
+
ColumnActionInput(
|
|
437
|
+
label="Aggregations",
|
|
438
|
+
actions=[
|
|
439
|
+
ActionOption("sum", "Sum"),
|
|
440
|
+
ActionOption("count", "Count"),
|
|
441
|
+
ActionOption("mean", "Average"),
|
|
442
|
+
"min", # plain strings also work
|
|
443
|
+
],
|
|
444
|
+
output_name_template="{column}_{action}",
|
|
445
|
+
show_group_by=True
|
|
446
|
+
)
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
component_type: Literal["ColumnActionInput"] = "ColumnActionInput"
|
|
450
|
+
input_type: InputType = "array"
|
|
451
|
+
|
|
452
|
+
# Configurable actions - list of action names or ActionOption tuples
|
|
453
|
+
actions: list[ActionSpec] = Field(default_factory=list)
|
|
454
|
+
"""Actions available for selection. Can be strings or ActionOption(value, label) tuples."""
|
|
455
|
+
|
|
456
|
+
# Template for auto-generating output names
|
|
457
|
+
# Supports placeholders: {column}, {action}
|
|
458
|
+
output_name_template: str = "{column}_{action}"
|
|
459
|
+
"""Template for generating default output names. Use {column} and {action} placeholders."""
|
|
460
|
+
|
|
461
|
+
# Optional grouping/ordering support
|
|
462
|
+
show_group_by: bool = False
|
|
463
|
+
"""Whether to show the group by column selector."""
|
|
464
|
+
|
|
465
|
+
show_order_by: bool = False
|
|
466
|
+
"""Whether to show the order by column selector."""
|
|
467
|
+
|
|
468
|
+
# Type filtering for column selection
|
|
469
|
+
data_type_filter_input: TypeSpec = Field(default="ALL", alias="data_types", repr=False, exclude=True)
|
|
470
|
+
"""Filter columns by data type. Defaults to ALL."""
|
|
471
|
+
|
|
472
|
+
class Config:
|
|
473
|
+
arbitrary_types_allowed = True
|
|
474
|
+
|
|
475
|
+
def __init__(self, **data):
|
|
476
|
+
super().__init__(**data)
|
|
477
|
+
# Initialize value if not set
|
|
478
|
+
if self.value is None:
|
|
479
|
+
self.value = {
|
|
480
|
+
"rows": [],
|
|
481
|
+
"group_by_columns": [],
|
|
482
|
+
"order_by_column": None,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
def set_value(self, value: Any):
|
|
486
|
+
"""
|
|
487
|
+
Sets the value from frontend.
|
|
488
|
+
"""
|
|
489
|
+
self.value = value
|
|
490
|
+
return self
|
|
491
|
+
|
|
492
|
+
@computed_field
|
|
493
|
+
@property
|
|
494
|
+
def data_types_filter(self) -> Literal["ALL"] | list[DataType]:
|
|
495
|
+
"""
|
|
496
|
+
A computed field that normalizes the `data_type_filter_input` into a
|
|
497
|
+
standardized format for the frontend.
|
|
498
|
+
"""
|
|
499
|
+
return normalize_input_to_data_types(self.data_type_filter_input)
|
|
500
|
+
|
|
501
|
+
def model_dump(self, **kwargs) -> dict:
|
|
502
|
+
"""
|
|
503
|
+
Serializes the component for the frontend.
|
|
504
|
+
"""
|
|
505
|
+
data = super().model_dump(**kwargs)
|
|
506
|
+
# Normalize actions to list of {value, label} objects for frontend
|
|
507
|
+
normalized_actions = []
|
|
508
|
+
for action in self.actions:
|
|
509
|
+
if isinstance(action, tuple):
|
|
510
|
+
normalized_actions.append({"value": action[0], "label": action[1]})
|
|
511
|
+
else:
|
|
512
|
+
normalized_actions.append({"value": action, "label": action})
|
|
513
|
+
data["actions"] = normalized_actions
|
|
514
|
+
data["output_name_template"] = self.output_name_template
|
|
515
|
+
data["show_group_by"] = self.show_group_by
|
|
516
|
+
data["show_order_by"] = self.show_order_by
|
|
517
|
+
if "data_types_filter" in data and data["data_types_filter"] != "ALL":
|
|
518
|
+
data["data_types"] = sorted([dt.value for dt in data["data_types_filter"]])
|
|
519
|
+
else:
|
|
520
|
+
data["data_types"] = "ALL"
|
|
521
|
+
return data
|
flowfile_core/main.py
CHANGED
|
@@ -4,10 +4,9 @@ import signal
|
|
|
4
4
|
from contextlib import asynccontextmanager
|
|
5
5
|
|
|
6
6
|
import uvicorn
|
|
7
|
-
from fastapi import FastAPI
|
|
7
|
+
from fastapi import BackgroundTasks, FastAPI
|
|
8
8
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
9
|
|
|
10
|
-
from flowfile_core import ServerRun
|
|
11
10
|
from flowfile_core.configs.flow_logger import clear_all_flow_logs
|
|
12
11
|
from flowfile_core.configs.settings import (
|
|
13
12
|
SERVER_HOST,
|
|
@@ -91,7 +90,7 @@ app.include_router(user_defined_components_router, prefix="/user_defined_compone
|
|
|
91
90
|
|
|
92
91
|
|
|
93
92
|
@app.post("/shutdown")
|
|
94
|
-
async def shutdown():
|
|
93
|
+
async def shutdown(background_tasks: BackgroundTasks):
|
|
95
94
|
"""An API endpoint to gracefully shut down the server.
|
|
96
95
|
|
|
97
96
|
This endpoint sets a flag that the Uvicorn server checks, allowing it
|
|
@@ -99,7 +98,6 @@ async def shutdown():
|
|
|
99
98
|
after the HTTP response has been sent.
|
|
100
99
|
"""
|
|
101
100
|
# Use a background task to trigger the shutdown after the response is sent
|
|
102
|
-
background_tasks = ServerRun()
|
|
103
101
|
background_tasks.add_task(trigger_shutdown)
|
|
104
102
|
return {"message": "Server is shutting down"}
|
|
105
103
|
|
flowfile_core/routes/public.py
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
1
3
|
from fastapi import APIRouter
|
|
2
4
|
from fastapi.responses import RedirectResponse
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from flowfile_core.auth.secrets import generate_master_key, is_master_key_configured
|
|
3
8
|
|
|
4
|
-
# Router setup
|
|
5
9
|
router = APIRouter()
|
|
6
10
|
|
|
7
11
|
|
|
12
|
+
class SetupStatus(BaseModel):
|
|
13
|
+
"""Response model for the setup status endpoint."""
|
|
14
|
+
|
|
15
|
+
setup_required: bool
|
|
16
|
+
master_key_configured: bool
|
|
17
|
+
mode: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GeneratedKey(BaseModel):
|
|
21
|
+
"""Response model for the generate key endpoint."""
|
|
22
|
+
|
|
23
|
+
key: str
|
|
24
|
+
instructions: str
|
|
25
|
+
|
|
26
|
+
|
|
8
27
|
@router.get("/", tags=["admin"])
|
|
9
28
|
async def docs_redirect():
|
|
10
29
|
"""Redirects to the documentation page."""
|
|
11
30
|
return RedirectResponse(url="/docs")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/health/status", response_model=SetupStatus, tags=["health"])
|
|
34
|
+
async def get_setup_status():
|
|
35
|
+
"""Get the current setup status of the application."""
|
|
36
|
+
mode = os.environ.get("FLOWFILE_MODE", "electron")
|
|
37
|
+
master_key_ok = is_master_key_configured()
|
|
38
|
+
return SetupStatus(
|
|
39
|
+
setup_required=not master_key_ok,
|
|
40
|
+
master_key_configured=master_key_ok,
|
|
41
|
+
mode=mode,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/setup/generate-key", response_model=GeneratedKey, tags=["setup"])
|
|
46
|
+
async def generate_key():
|
|
47
|
+
"""Generate a new master encryption key."""
|
|
48
|
+
key = generate_master_key()
|
|
49
|
+
instructions = (
|
|
50
|
+
f'Add to your .env file:\n FLOWFILE_MASTER_KEY="{key}"\n\n'
|
|
51
|
+
"Then restart: docker-compose down && docker-compose up"
|
|
52
|
+
)
|
|
53
|
+
return GeneratedKey(key=key, instructions=instructions)
|
|
@@ -14,14 +14,20 @@ AuthMethod = Literal[
|
|
|
14
14
|
]
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def encrypt_for_worker(secret_value: SecretStr | None) -> str | None:
|
|
17
|
+
def encrypt_for_worker(secret_value: SecretStr | None, user_id: int) -> str | None:
|
|
18
18
|
"""
|
|
19
|
-
Encrypts a secret value for use in worker contexts.
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
Encrypts a secret value for use in worker contexts using per-user key derivation.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
secret_value: The secret value to encrypt
|
|
23
|
+
user_id: The user ID for key derivation
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Encrypted secret with embedded user_id, or None if secret_value is None
|
|
22
27
|
"""
|
|
23
28
|
if secret_value is not None:
|
|
24
|
-
return encrypt_secret(secret_value.get_secret_value())
|
|
29
|
+
return encrypt_secret(secret_value.get_secret_value(), user_id)
|
|
30
|
+
return None
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
class AuthSettingsInput(BaseModel):
|
|
@@ -80,25 +86,31 @@ class FullCloudStorageConnection(AuthSettingsInput):
|
|
|
80
86
|
endpoint_url: str | None = None
|
|
81
87
|
verify_ssl: bool = True
|
|
82
88
|
|
|
83
|
-
def get_worker_interface(self) -> "FullCloudStorageConnectionWorkerInterface":
|
|
89
|
+
def get_worker_interface(self, user_id: int) -> "FullCloudStorageConnectionWorkerInterface":
|
|
84
90
|
"""
|
|
85
|
-
Convert to a
|
|
91
|
+
Convert to a worker interface model with encrypted secrets.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
user_id: The user ID for per-user key derivation
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
FullCloudStorageConnectionWorkerInterface with encrypted secrets
|
|
86
98
|
"""
|
|
87
99
|
return FullCloudStorageConnectionWorkerInterface(
|
|
88
100
|
storage_type=self.storage_type,
|
|
89
101
|
auth_method=self.auth_method,
|
|
90
102
|
connection_name=self.connection_name,
|
|
91
103
|
aws_allow_unsafe_html=self.aws_allow_unsafe_html,
|
|
92
|
-
aws_secret_access_key=encrypt_for_worker(self.aws_secret_access_key),
|
|
104
|
+
aws_secret_access_key=encrypt_for_worker(self.aws_secret_access_key, user_id),
|
|
93
105
|
aws_region=self.aws_region,
|
|
94
106
|
aws_access_key_id=self.aws_access_key_id,
|
|
95
107
|
aws_role_arn=self.aws_role_arn,
|
|
96
|
-
aws_session_token=encrypt_for_worker(self.aws_session_token),
|
|
108
|
+
aws_session_token=encrypt_for_worker(self.aws_session_token, user_id),
|
|
97
109
|
azure_account_name=self.azure_account_name,
|
|
98
110
|
azure_tenant_id=self.azure_tenant_id,
|
|
99
|
-
azure_account_key=encrypt_for_worker(self.azure_account_key),
|
|
111
|
+
azure_account_key=encrypt_for_worker(self.azure_account_key, user_id),
|
|
100
112
|
azure_client_id=self.azure_client_id,
|
|
101
|
-
azure_client_secret=encrypt_for_worker(self.azure_client_secret),
|
|
113
|
+
azure_client_secret=encrypt_for_worker(self.azure_client_secret, user_id),
|
|
102
114
|
endpoint_url=self.endpoint_url,
|
|
103
115
|
verify_ssl=self.verify_ssl,
|
|
104
116
|
)
|
|
@@ -202,18 +214,30 @@ def get_cloud_storage_write_settings_worker_interface(
|
|
|
202
214
|
write_settings: CloudStorageWriteSettings,
|
|
203
215
|
connection: FullCloudStorageConnection,
|
|
204
216
|
lf: pl.LazyFrame,
|
|
217
|
+
user_id: int,
|
|
205
218
|
flowfile_flow_id: int = 1,
|
|
206
219
|
flowfile_node_id: int | str = -1,
|
|
207
220
|
) -> CloudStorageWriteSettingsWorkerInterface:
|
|
208
221
|
"""
|
|
209
|
-
Convert to a worker interface model with
|
|
222
|
+
Convert to a worker interface model with encrypted secrets.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
write_settings: Cloud storage write settings
|
|
226
|
+
connection: Full cloud storage connection with secrets
|
|
227
|
+
lf: LazyFrame to serialize
|
|
228
|
+
user_id: User ID for per-user key derivation
|
|
229
|
+
flowfile_flow_id: Flow ID for tracking
|
|
230
|
+
flowfile_node_id: Node ID for tracking
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
CloudStorageWriteSettingsWorkerInterface ready for worker
|
|
210
234
|
"""
|
|
211
235
|
operation = base64.b64encode(lf.serialize()).decode()
|
|
212
236
|
|
|
213
237
|
return CloudStorageWriteSettingsWorkerInterface(
|
|
214
238
|
operation=operation,
|
|
215
239
|
write_settings=write_settings.get_write_setting_worker_interface(),
|
|
216
|
-
connection=connection.get_worker_interface(),
|
|
217
|
-
flowfile_flow_id=flowfile_flow_id,
|
|
218
|
-
flowfile_node_id=flowfile_node_id,
|
|
240
|
+
connection=connection.get_worker_interface(user_id),
|
|
241
|
+
flowfile_flow_id=flowfile_flow_id,
|
|
242
|
+
flowfile_node_id=flowfile_node_id,
|
|
219
243
|
)
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
1
3
|
from cryptography.fernet import Fernet
|
|
4
|
+
from cryptography.hazmat.primitives import hashes
|
|
5
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
2
6
|
from fastapi.exceptions import HTTPException
|
|
3
7
|
from pydantic import SecretStr
|
|
4
8
|
from sqlalchemy import and_
|
|
@@ -9,21 +13,118 @@ from flowfile_core.auth.secrets import get_master_key
|
|
|
9
13
|
from flowfile_core.database import models as db_models
|
|
10
14
|
from flowfile_core.database.connection import get_db_context
|
|
11
15
|
|
|
16
|
+
# Version identifier for key derivation scheme (allows future migrations)
|
|
17
|
+
KEY_DERIVATION_VERSION = b"flowfile-secrets-v1"
|
|
18
|
+
|
|
19
|
+
# Encrypted secret format: $ffsec$1${user_id}${fernet_token}
|
|
20
|
+
# This embeds the user_id so the worker can derive the correct key
|
|
21
|
+
SECRET_FORMAT_PREFIX = "$ffsec$1$"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def derive_user_key(user_id: int) -> bytes:
|
|
25
|
+
"""
|
|
26
|
+
Derive a user-specific encryption key from the master key using HKDF.
|
|
27
|
+
|
|
28
|
+
This provides cryptographic isolation between users - each user's secrets
|
|
29
|
+
are encrypted with a unique key derived from the master key.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
user_id: The unique identifier for the user
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
bytes: A 32-byte URL-safe base64-encoded key suitable for Fernet
|
|
36
|
+
"""
|
|
37
|
+
master_key = get_master_key().encode()
|
|
12
38
|
|
|
13
|
-
|
|
14
|
-
|
|
39
|
+
# Use HKDF to derive a user-specific key
|
|
40
|
+
hkdf = HKDF(
|
|
41
|
+
algorithm=hashes.SHA256(),
|
|
42
|
+
length=32, # Fernet requires 32 bytes
|
|
43
|
+
salt=KEY_DERIVATION_VERSION, # Static salt is fine for key derivation
|
|
44
|
+
info=f"user-{user_id}".encode(), # User-specific context
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Derive raw key material and encode for Fernet
|
|
48
|
+
derived_key = hkdf.derive(master_key)
|
|
49
|
+
return base64.urlsafe_b64encode(derived_key)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _encrypt_with_master_key(secret_value: str) -> str:
|
|
53
|
+
"""Legacy encryption using master key directly (for backward compatibility)."""
|
|
15
54
|
key = get_master_key().encode()
|
|
16
55
|
f = Fernet(key)
|
|
17
56
|
return f.encrypt(secret_value.encode()).decode()
|
|
18
57
|
|
|
19
58
|
|
|
20
|
-
def
|
|
21
|
-
"""
|
|
59
|
+
def _decrypt_with_master_key(encrypted_value: str) -> SecretStr:
|
|
60
|
+
"""Legacy decryption using master key directly (for backward compatibility)."""
|
|
22
61
|
key = get_master_key().encode()
|
|
23
62
|
f = Fernet(key)
|
|
24
63
|
return SecretStr(f.decrypt(encrypted_value.encode()).decode())
|
|
25
64
|
|
|
26
65
|
|
|
66
|
+
def encrypt_secret(secret_value: str, user_id: int) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Encrypt a secret value using a user-specific derived key.
|
|
69
|
+
|
|
70
|
+
The encrypted format embeds the user_id so it can be decrypted
|
|
71
|
+
without knowing the user context (e.g., by the worker service).
|
|
72
|
+
|
|
73
|
+
Format: $ffsec$1${user_id}${fernet_token}
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
secret_value: The plaintext secret to encrypt
|
|
77
|
+
user_id: The user ID to derive the encryption key for
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
str: The encrypted value with embedded user_id
|
|
81
|
+
"""
|
|
82
|
+
key = derive_user_key(user_id)
|
|
83
|
+
f = Fernet(key)
|
|
84
|
+
fernet_token = f.encrypt(secret_value.encode()).decode()
|
|
85
|
+
return f"{SECRET_FORMAT_PREFIX}{user_id}${fernet_token}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def decrypt_secret(encrypted_value: str, user_id: int | None = None) -> SecretStr:
|
|
89
|
+
"""
|
|
90
|
+
Decrypt an encrypted value.
|
|
91
|
+
|
|
92
|
+
Supports both new format (with embedded user_id) and legacy format.
|
|
93
|
+
- New format: $ffsec$1${user_id}${fernet_token} - user_id extracted automatically
|
|
94
|
+
- Legacy format: raw Fernet token - requires user_id parameter or uses master key
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
encrypted_value: The encrypted secret
|
|
98
|
+
user_id: Optional user ID (required for legacy secrets, ignored for new format)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
SecretStr: The decrypted value wrapped in SecretStr
|
|
102
|
+
"""
|
|
103
|
+
# Check for new versioned format with embedded user_id
|
|
104
|
+
if encrypted_value.startswith(SECRET_FORMAT_PREFIX):
|
|
105
|
+
# Parse: $ffsec$1${user_id}${fernet_token}
|
|
106
|
+
remainder = encrypted_value[len(SECRET_FORMAT_PREFIX):]
|
|
107
|
+
parts = remainder.split("$", 1)
|
|
108
|
+
if len(parts) != 2:
|
|
109
|
+
raise ValueError("Invalid encrypted secret format")
|
|
110
|
+
|
|
111
|
+
embedded_user_id = int(parts[0])
|
|
112
|
+
fernet_token = parts[1]
|
|
113
|
+
|
|
114
|
+
key = derive_user_key(embedded_user_id)
|
|
115
|
+
f = Fernet(key)
|
|
116
|
+
return SecretStr(f.decrypt(fernet_token.encode()).decode())
|
|
117
|
+
|
|
118
|
+
# Legacy format - use provided user_id or fall back to master key
|
|
119
|
+
if user_id is not None:
|
|
120
|
+
key = derive_user_key(user_id)
|
|
121
|
+
f = Fernet(key)
|
|
122
|
+
return SecretStr(f.decrypt(encrypted_value.encode()).decode())
|
|
123
|
+
|
|
124
|
+
# Fall back to master key for legacy secrets without user context
|
|
125
|
+
return _decrypt_with_master_key(encrypted_value)
|
|
126
|
+
|
|
127
|
+
|
|
27
128
|
def get_encrypted_secret(current_user_id: int, secret_name: str) -> str | None:
|
|
28
129
|
with get_db_context() as db:
|
|
29
130
|
user_id = current_user_id
|
|
@@ -39,13 +140,13 @@ def get_encrypted_secret(current_user_id: int, secret_name: str) -> str | None:
|
|
|
39
140
|
|
|
40
141
|
|
|
41
142
|
def store_secret(db: Session, secret: SecretInput, user_id: int) -> db_models.Secret:
|
|
42
|
-
encrypted_value = encrypt_secret(secret.value.get_secret_value())
|
|
143
|
+
encrypted_value = encrypt_secret(secret.value.get_secret_value(), user_id)
|
|
43
144
|
|
|
44
145
|
# Store in database
|
|
45
146
|
db_secret = db_models.Secret(
|
|
46
147
|
name=secret.name,
|
|
47
148
|
encrypted_value=encrypted_value,
|
|
48
|
-
iv="", #
|
|
149
|
+
iv="", # Legacy field, not used with current encryption
|
|
49
150
|
user_id=user_id,
|
|
50
151
|
)
|
|
51
152
|
db.add(db_secret)
|
flowfile_frame/__init__.py
CHANGED
|
@@ -54,6 +54,17 @@ from flowfile_frame.cloud_storage.secret_manager import (
|
|
|
54
54
|
get_all_available_cloud_storage_connections,
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
+
# Database I/O
|
|
58
|
+
from flowfile_frame.database import (
|
|
59
|
+
create_database_connection,
|
|
60
|
+
create_database_connection_if_not_exists,
|
|
61
|
+
del_database_connection,
|
|
62
|
+
get_all_available_database_connections,
|
|
63
|
+
get_database_connection_by_name,
|
|
64
|
+
read_database,
|
|
65
|
+
write_database,
|
|
66
|
+
)
|
|
67
|
+
|
|
57
68
|
# Commonly used functions
|
|
58
69
|
from flowfile_frame.expr import ( # noqa: F401
|
|
59
70
|
col,
|