Răsfoiți Sursa

feat: grant a collection to a workflow from the node that needs it

A workflow can only reach collections it has been granted, which is what
stops one workflow quietly reading another's data. The consequence was a
collection dropdown that is empty for any new workflow, with the fix living
somewhere else entirely - Settings, Storage, type the name, come back.

The collections this workflow cannot reach are now listed under the picker
with the grant offered in place. Choosing one grants exactly the access the
field needs, selects it, and says so. The grant is a workflow setting like
any other, saved with the workflow, so nothing is granted behind anyone's
back.

Three things the old dropdown did not say, all of which left someone hunting:
a collection that is set but no longer granted is kept as a marked entry
rather than rendering blank and being wiped by the next save; a field that
needs read-write says so when the collection is only granted read-only,
instead of just omitting it; and the protected system collections are named
as unavailable rather than silently absent.

Same control in the Configurator, which had no collection picker at all - a
collection setting moved onto one was a plain text box.

Verified end to end on a workflow with no grants: the picker was empty and
said so, granting anime_images from the node put
{"anime_images": "read-write"} into the workflow's storage settings, and
after saving the workflow the node inserted a document successfully - so the
runner honours a grant made this way. 60/60.
fszontagh 1 lună în urmă
părinte
comite
949ac59374

+ 139 - 0
webui/src/components/workflow/CollectionSelect.tsx

@@ -0,0 +1,139 @@
+import { Lock, Plus, ShieldAlert, AlertTriangle } from 'lucide-react'
+
+export interface StorageCollection {
+  name: string
+  access: 'none' | 'read-only' | 'read-write'
+  system?: boolean
+}
+
+interface CollectionSelectProps {
+  prop: any
+  value: any
+  // Every collection in the database, each carrying the access this workflow
+  // has to it.
+  collections: StorageCollection[]
+  // Grant this workflow access to a collection. Absent when granting is not
+  // possible from here - viewing an execution, say.
+  onGrant?: (name: string, access: 'read-only' | 'read-write') => void
+  onChange: (value: string) => void
+}
+
+/**
+ * The picker for a setting that names a storage collection.
+ *
+ * A workflow can only reach collections it has been granted, which is what
+ * stops one workflow quietly reading another's data. The consequence is a
+ * dropdown that is often empty, and the fix used to live somewhere else
+ * entirely - workflow Settings, Storage, add the name, come back. So the
+ * collections this workflow cannot reach are listed here too, with the grant
+ * offered in place.
+ */
+
+function neededAccess(prop: any): 'read-only' | 'read-write' {
+  return prop?.dynamicOptions?.filter?.access === 'read-write' ? 'read-write' : 'read-only'
+}
+
+function satisfies(collection: StorageCollection, wanted: 'read-only' | 'read-write'): boolean {
+  if (collection.access === 'none') return false
+  return wanted === 'read-write' ? collection.access === 'read-write' : true
+}
+
+export function CollectionSelect({
+  prop,
+  value,
+  collections,
+  onGrant,
+  onChange,
+}: CollectionSelectProps) {
+  const wanted = neededAccess(prop)
+  const current = String(value ?? prop?.default ?? '')
+
+  const usable = collections.filter((c) => satisfies(c, wanted))
+  // Real collections this workflow has not been given, or has been given too
+  // little of. Offering them is the point of this control.
+  const grantable = collections.filter((c) => !c.system && !satisfies(c, wanted))
+  // Never offerable, and saying so beats leaving someone hunting for why a
+  // collection they can see is not in the list.
+  const locked = collections.filter((c) => c.system)
+
+  // A name that is set but is not among the usable ones - granted and then
+  // revoked, or typed before the grant existed. A select whose value is not an
+  // option renders blank and the next save writes the blank away.
+  const orphaned = current !== '' && !usable.some((c) => c.name === current)
+  const orphanIsGrantable = grantable.some((c) => c.name === current)
+
+  return (
+    <div className="space-y-1.5">
+      <select
+        value={current}
+        onChange={(e) => onChange(e.target.value)}
+        className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+      >
+        <option value="">Select collection...</option>
+        {usable.map((c) => (
+          <option key={c.name} value={c.name}>
+            {c.name} ({c.access})
+          </option>
+        ))}
+        {orphaned && <option value={current}>{current} - not available here</option>}
+      </select>
+
+      {orphaned && (
+        <div className="flex items-start gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+          {orphanIsGrantable
+            ? `This workflow cannot ${wanted === 'read-write' ? 'write to' : 'read'} "${current}" yet. Give it access below, or the node will fail when it runs.`
+            : `"${current}" is not a collection this workflow can use. Kept so it is not lost.`}
+        </div>
+      )}
+
+      {usable.length === 0 && !orphaned && (
+        <div className="flex items-start gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
+          <Lock className="w-3.5 h-3.5 shrink-0 mt-px" />
+          This workflow has no collection it can {wanted === 'read-write' ? 'write to' : 'read'} yet.
+        </div>
+      )}
+
+      {onGrant && grantable.length > 0 && (
+        <div className="rounded-lg border border-gray-200 dark:border-slate-700 p-2 space-y-1">
+          <div className="text-[11px] text-gray-500 dark:text-gray-400">
+            Not available to this workflow. Granting one adds it to the workflow's
+            storage settings, and takes effect when the workflow is saved.
+          </div>
+          <div className="flex flex-wrap gap-1.5">
+            {grantable.map((c) => (
+              <button
+                key={c.name}
+                type="button"
+                onClick={() => {
+                  onGrant(c.name, wanted)
+                  onChange(c.name)
+                }}
+                title={
+                  c.access === 'none'
+                    ? `Give this workflow ${wanted} access to ${c.name}`
+                    : `${c.name} is ${c.access} here, and this needs ${wanted}`
+                }
+                className="flex items-center gap-1 px-2 py-1 text-[11px] rounded border border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20"
+              >
+                <Plus className="w-3 h-3" />
+                {c.name}
+                {c.access !== 'none' && (
+                  <span className="text-gray-400 dark:text-gray-500">({c.access})</span>
+                )}
+              </button>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {locked.length > 0 && (
+        <div className="flex items-start gap-1.5 text-[11px] text-gray-400 dark:text-gray-500">
+          <ShieldAlert className="w-3.5 h-3.5 shrink-0 mt-px" />
+          {locked.map((c) => c.name).join(', ')} {locked.length === 1 ? 'is' : 'are'} kept
+          for the system and cannot be given to a workflow.
+        </div>
+      )}
+    </div>
+  )
+}

+ 19 - 0
webui/src/components/workflow/ConfiguratorFields.tsx

@@ -3,6 +3,7 @@ import { Plus, X, AlertTriangle, Link2 } from 'lucide-react'
 import { NodeDefinition } from '../../api/workflows'
 import { CredentialInfo } from '../../api/credentials'
 import { CredentialSelect } from './CredentialSelect'
+import { CollectionSelect, StorageCollection } from './CollectionSelect'
 import { isCredentialKey } from './fieldGroups'
 import { EnumSelect } from './EnumSelect'
 
@@ -29,6 +30,10 @@ interface ConfiguratorFieldsProps {
   // Names for the types nodes registered, so a credential reads as what it is
   // for rather than how it is stored.
   credentialTypeLabels: Record<string, string>
+  // Collections and what this workflow may do with each, so a collection moved
+  // onto a Configurator gets the same picker it has on the node.
+  storageCollections: StorageCollection[]
+  onGrantCollection?: (name: string, access: 'read-only' | 'read-write') => void
   editingConfig: Record<string, any>
   onConfigChange: (config: Record<string, any>) => void
 }
@@ -54,6 +59,8 @@ export function ConfiguratorFields({
   nodeDefs,
   credentials,
   credentialTypeLabels,
+  storageCollections,
+  onGrantCollection,
   editingConfig,
   onConfigChange,
 }: ConfiguratorFieldsProps) {
@@ -146,6 +153,18 @@ export function ConfiguratorFields({
       )
     }
 
+    if (prop?.dynamicOptions?.source === 'storage.collections') {
+      return (
+        <CollectionSelect
+          prop={prop}
+          value={setting.value}
+          collections={storageCollections}
+          onGrant={onGrantCollection}
+          onChange={(v) => setValue(index, v)}
+        />
+      )
+    }
+
     if (Array.isArray(prop?.enum)) {
       return (
         <EnumSelect

+ 14 - 23
webui/src/components/workflow/NodeConfigModal.tsx

@@ -7,6 +7,7 @@ import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { NodeOptionsSelect } from './NodeOptionsSelect'
 import { groupFields, isCredentialKey } from './fieldGroups'
 import { CredentialSelect } from './CredentialSelect'
+import { CollectionSelect } from './CollectionSelect'
 import { EnumSelect } from './EnumSelect'
 import { KeyValueEditor } from './KeyValueEditor'
 import { ArrayFieldEditor } from './ArrayFieldEditor'
@@ -45,6 +46,9 @@ interface NodeConfigModalProps {
   editingConfig: Record<string, any>
   nodeDefs: NodeDefinition[]
   storageCollections: StorageCollection[]
+  // Give this workflow access to a collection without leaving the node. The
+  // grant lands in the workflow's storage settings and is saved with it.
+  onGrantCollection?: (name: string, access: 'read-only' | 'read-write') => void
   showDataPanel: boolean
   onConfigChange: (config: Record<string, any>) => void
   onSave: () => void
@@ -62,6 +66,7 @@ export function NodeConfigModal({
   editingConfig,
   nodeDefs,
   storageCollections,
+  onGrantCollection,
   showDataPanel,
   onConfigChange,
   onSave,
@@ -484,6 +489,8 @@ export function NodeConfigModal({
                   nodeDefs={nodeDefs}
                   credentials={credentials}
                   credentialTypeLabels={credentialTypeLabels}
+                  storageCollections={storageCollections}
+                  onGrantCollection={onGrantCollection}
                   editingConfig={editingConfig}
                   onConfigChange={onConfigChange}
                 />
@@ -697,29 +704,13 @@ export function NodeConfigModal({
                         onChange={(next) => onConfigChange({ ...editingConfig, [key]: next })}
                       />
                     ) : prop.dynamicOptions?.source === 'storage.collections' ? (
-                      /* Dynamic collection selector */
-                      <select
-                        value={editingConfig[key] ?? prop.default ?? ''}
-                        onChange={(e) =>
-                          onConfigChange({ ...editingConfig, [key]: e.target.value })
-                        }
-                        className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
-                      >
-                        <option value="">Select collection...</option>
-                        {storageCollections
-                          .filter(c => {
-                            // Filter by access level if specified
-                            if (prop.dynamicOptions?.filter?.access === 'read-write') {
-                              return c.access === 'read-write'
-                            }
-                            return c.access !== 'none'
-                          })
-                          .map((coll) => (
-                            <option key={coll.name} value={coll.name}>
-                              {coll.name} {coll.system ? '(system)' : `(${coll.access})`}
-                            </option>
-                          ))}
-                      </select>
+                      <CollectionSelect
+                        prop={prop}
+                        value={editingConfig[key]}
+                        collections={storageCollections}
+                        onGrant={onGrantCollection}
+                        onChange={(v) => onConfigChange({ ...editingConfig, [key]: v })}
+                      />
                     ) : isCredentialKey(key, prop) ? (
                       /* Any setting that holds a stored credential, whether or
                          not the node asked for a picker - an id in a text box

+ 24 - 0
webui/src/pages/WorkflowEditorPage.tsx

@@ -689,6 +689,29 @@ function WorkflowEditorInner() {
     })
   }, [allCollectionsData, workflowSettings])
 
+  // Give this workflow access to a collection from wherever the need shows up -
+  // a node's collection field, usually - rather than only from the settings
+  // dialog. It is the same setting either way, and it is saved with the
+  // workflow, so nothing is granted behind the user's back.
+  const grantCollection = useCallback((name: string, access: 'read-only' | 'read-write') => {
+    if (PROTECTED_COLLECTIONS.includes(name)) {
+      showToast('error', `${name} is kept for the system and cannot be given to a workflow`)
+      return
+    }
+    setWorkflowSettings((current) => {
+      const permissions = current?.storagePermissions || { collections: {}, defaultAccess: 'none' }
+      return {
+        ...current,
+        storagePermissions: {
+          ...permissions,
+          collections: { ...(permissions.collections || {}), [name]: access },
+        },
+      }
+    })
+    setHasChanges(true)
+    showToast('success', `This workflow can now ${access === 'read-write' ? 'read and write' : 'read'} ${name}. Save to keep it.`)
+  }, [showToast])
+
   const saveMutation = useMutation({
     mutationFn: (data: Partial<Workflow>) => workflowsApi.update(id!, data),
     onSuccess: () => {
@@ -2838,6 +2861,7 @@ function WorkflowEditorInner() {
           editingConfig={editingConfig}
           nodeDefs={nodeDefs}
           storageCollections={storageCollections}
+          onGrantCollection={isViewingExecution ? undefined : grantCollection}
           showDataPanel={showDataPanel}
           onConfigChange={setEditingConfig}
           onSave={saveNodeConfig}