Make changing engine-run code superuser-only
Writing node source, installing modules and any flow save that adds, retypes or repoints a code node (Function, Command), edits a Command node's settings or changes a node's resources.env now answer 403 for everyone but a superuser. MCP tools and the assistant reach the same routes, so they inherit the gate. Node types report runs_code. The editor shows code read-only with a note, disables a Command node's settings, leaves code nodes and reusable nodes out of the palette and paste, and the Modules page offers no install.
This commit is contained in:
@@ -2573,6 +2573,11 @@ export const NodeTypeInfoSchema = {
|
||||
title: 'Has Source',
|
||||
default: false
|
||||
},
|
||||
runs_code: {
|
||||
type: 'boolean',
|
||||
title: 'Runs Code',
|
||||
default: false
|
||||
},
|
||||
free_params: {
|
||||
type: 'boolean',
|
||||
title: 'Free Params',
|
||||
|
||||
@@ -1478,6 +1478,8 @@ export class ModulesService {
|
||||
* Apply Modules
|
||||
* Install exactly these requirements, then hand them to the workers.
|
||||
*
|
||||
* A superuser's: a package runs its own code on install and on import.
|
||||
*
|
||||
* A manifest that does not resolve changes nothing: the venv is left as it
|
||||
* was and the stored manifest is only written once the install succeeded.
|
||||
*
|
||||
|
||||
@@ -888,6 +888,7 @@ export type NodeTypeInfo = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
has_source?: boolean;
|
||||
runs_code?: boolean;
|
||||
free_params?: boolean;
|
||||
plugin?: (string | null);
|
||||
};
|
||||
|
||||
@@ -30,13 +30,14 @@ export function CommandPalette({
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
flows: FlowSummary[]
|
||||
onAddNode: (type: string) => void
|
||||
onAddSharedNode: (libName: string) => void
|
||||
/** Absent for an account that may not add code, which a shared node is. */
|
||||
onAddSharedNode?: (libName: string) => void
|
||||
onRun: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { data: libraryData } = useQuery({
|
||||
...libraryQueryOptions(),
|
||||
enabled: open,
|
||||
enabled: open && Boolean(onAddSharedNode),
|
||||
})
|
||||
|
||||
// Picking an item re-renders the canvas, which can interrupt the dialog's
|
||||
@@ -44,7 +45,7 @@ export function CommandPalette({
|
||||
// dialog outright is deterministic; the palette does not need to fade out.
|
||||
if (!open) return null
|
||||
|
||||
const library = libraryData ?? []
|
||||
const library = onAddSharedNode ? (libraryData ?? []) : []
|
||||
|
||||
const close = (action: () => void) => {
|
||||
onOpenChange(false)
|
||||
@@ -85,7 +86,7 @@ export function CommandPalette({
|
||||
<CommandItem
|
||||
key={entry.name}
|
||||
value={`shared ${entry.name}`}
|
||||
onSelect={() => close(() => onAddSharedNode(entry.name))}
|
||||
onSelect={() => close(() => onAddSharedNode?.(entry.name))}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-mono">{entry.name}</span>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { scaleIn } from "@/lib/motion"
|
||||
@@ -367,6 +368,22 @@ function FlowEditorInner({
|
||||
[nodeTypeInfo],
|
||||
)
|
||||
|
||||
// Which types run code of their own, a source or a command. Adding one is a
|
||||
// superuser's, so nobody else is offered one.
|
||||
const { user } = useAuth()
|
||||
const mayEditCode = Boolean(user?.is_superuser)
|
||||
const lockedTypes = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
mayEditCode
|
||||
? []
|
||||
: (nodeTypeInfo ?? [])
|
||||
.filter((info) => info.runs_code)
|
||||
.map((info) => info.type),
|
||||
),
|
||||
[nodeTypeInfo, mayEditCode],
|
||||
)
|
||||
|
||||
// Which types came from an installed connector rather than the engine. They
|
||||
// cannot be in the icon map, so they share one.
|
||||
const pluginTypes = useMemo(
|
||||
@@ -1042,9 +1059,18 @@ function FlowEditorInner({
|
||||
return
|
||||
}
|
||||
|
||||
// A node that runs code is a superuser's to add, so anyone else pastes
|
||||
// the rest without it.
|
||||
const nodes = (clipboard.nodes ?? []).filter(
|
||||
(node) => !lockedTypes.has(node.type ?? "python"),
|
||||
)
|
||||
if (nodes.length < (clipboard.nodes ?? []).length) {
|
||||
notify("Only a superuser can add nodes that run code.", "error")
|
||||
}
|
||||
|
||||
let pool = definitions
|
||||
const pasted: NodeDef_Input[] = []
|
||||
for (const node of clipboard.nodes ?? []) {
|
||||
for (const node of nodes) {
|
||||
const copy = { ...node, id: uniqueNodeId(pool, node.id) }
|
||||
pool = [...pool, copy]
|
||||
pasted.push(copy)
|
||||
@@ -1063,13 +1089,20 @@ function FlowEditorInner({
|
||||
commit(pool)
|
||||
setSelectedId(pasted[pasted.length - 1].id)
|
||||
|
||||
clipboard.nodes.forEach((node, index) => {
|
||||
nodes.forEach((node, index) => {
|
||||
const code = clipboard.sources?.[node.id]
|
||||
if (code !== undefined) {
|
||||
sourceMutation.mutate({ nodeId: pasted[index].id, code })
|
||||
}
|
||||
})
|
||||
}, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate])
|
||||
}, [
|
||||
canvasNodes,
|
||||
commit,
|
||||
definitions,
|
||||
lockedTypes,
|
||||
setCanvasNodes,
|
||||
sourceMutation.mutate,
|
||||
])
|
||||
|
||||
useShortcuts(
|
||||
{
|
||||
@@ -1335,10 +1368,14 @@ function FlowEditorInner({
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
nodeTypes={(nodeTypeInfo ?? []).filter(
|
||||
(info) => !lockedTypes.has(info.type),
|
||||
)}
|
||||
flows={flows.data}
|
||||
onAddNode={addNode}
|
||||
onAddSharedNode={(libName) => addNode("python", libName)}
|
||||
onAddSharedNode={
|
||||
mayEditCode ? (libName) => addNode("python", libName) : undefined
|
||||
}
|
||||
onRun={startRun}
|
||||
/>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -1204,6 +1205,9 @@ const PLACEHOLDER: Record<DType, string> = {
|
||||
video: 'fluksio.save_artifact(b"", "clip.mp4", media_type="video/mp4")',
|
||||
}
|
||||
|
||||
/** Why a code editor or an exec node's settings are read-only. */
|
||||
const CODE_LOCKED = "Only a superuser can change code the engine runs."
|
||||
|
||||
const SCAFFOLD_DOC =
|
||||
'"""A new node. Return a dict keyed by your output ports."""'
|
||||
|
||||
@@ -1293,6 +1297,12 @@ function PanelBody({
|
||||
...nodeSourceQueryOptions(flow, node.id),
|
||||
enabled: hasSource,
|
||||
})
|
||||
// Code the engine runs is a superuser's to change; the server refuses
|
||||
// anyone else, so the panel shows it read-only rather than failing a save.
|
||||
const { user } = useAuth()
|
||||
const mayEditCode = Boolean(user?.is_superuser)
|
||||
const settingsAreCode =
|
||||
!mayEditCode && Boolean(nodeType?.runs_code) && !hasSource
|
||||
|
||||
const [code, setCode] = useState<string | null>(null)
|
||||
// The port the X is dropping or its name field renaming, noted just before
|
||||
@@ -1333,7 +1343,14 @@ function PanelBody({
|
||||
const editNode = (next: NodeDef_Input) => {
|
||||
onChange(next)
|
||||
const current = code ?? source?.code
|
||||
if (!hasSource || next.source_ref || origin || current === undefined) return
|
||||
if (
|
||||
!hasSource ||
|
||||
!mayEditCode ||
|
||||
next.source_ref ||
|
||||
origin ||
|
||||
current === undefined
|
||||
)
|
||||
return
|
||||
const wanted = scaffoldFor(next)
|
||||
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
|
||||
}
|
||||
@@ -1389,6 +1406,16 @@ function PanelBody({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const paramsForm = (
|
||||
<ParamsForm
|
||||
type={node.type}
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
provides={node.provides ?? []}
|
||||
onChange={(params) => editNode({ ...node, params })}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1434,13 +1461,20 @@ function PanelBody({
|
||||
portMove.current = [portName(spec), null]
|
||||
}}
|
||||
/>
|
||||
<ParamsForm
|
||||
type={node.type}
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
provides={node.provides ?? []}
|
||||
onChange={(params) => editNode({ ...node, params })}
|
||||
/>
|
||||
{settingsAreCode ? (
|
||||
/* An exec node's command is its code, so the whole form is. */
|
||||
<fieldset disabled className="grid min-w-0 gap-2">
|
||||
{paramsForm}
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="node-settings-locked"
|
||||
>
|
||||
{CODE_LOCKED}
|
||||
</p>
|
||||
</fieldset>
|
||||
) : (
|
||||
paramsForm
|
||||
)}
|
||||
{nodeType?.free_params ? (
|
||||
<FreeParamsForm
|
||||
params={node.params ?? {}}
|
||||
@@ -1524,7 +1558,14 @@ function PanelBody({
|
||||
<span className="font-mono">{origin.repo || "a repository"}</span>
|
||||
. Edit it there and sync again.
|
||||
</p>
|
||||
) : null}
|
||||
) : mayEditCode ? null : (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="node-source-locked"
|
||||
>
|
||||
{CODE_LOCKED}
|
||||
</p>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -1534,7 +1575,7 @@ function PanelBody({
|
||||
<NodeEditor
|
||||
value={code ?? source?.code ?? ""}
|
||||
onChange={editCode}
|
||||
readOnly={Boolean(origin)}
|
||||
readOnly={Boolean(origin) || !mayEditCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from "react"
|
||||
import { type ApiError, ModulesService } from "@/client"
|
||||
import { InfoTip } from "@/components/Flow/SidePanel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
@@ -32,6 +33,9 @@ function Modules() {
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
// A package runs its own code on install, so installing is a superuser's.
|
||||
const { user } = useAuth()
|
||||
const mayInstall = Boolean(user?.is_superuser)
|
||||
// Null until the field is touched, so what is stored shows through until
|
||||
// someone actually starts editing it.
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
@@ -84,7 +88,14 @@ function Modules() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{adopted ? null : (
|
||||
{adopted ? null : !mayInstall ? (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="modules-locked"
|
||||
>
|
||||
Only a superuser can install packages.
|
||||
</p>
|
||||
) : (
|
||||
<section className="grid gap-3">
|
||||
<h2 className={SECTION}>Requirements</h2>
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user