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:
2026-10-06 11:36:30 +02:00
parent 2914d860b1
commit 6802cd2203
19 changed files with 391 additions and 26 deletions
+5
View File
@@ -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',
+2
View File
@@ -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.
*
+1
View File
@@ -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 -5
View File
@@ -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}
/>
+51 -10
View File
@@ -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>
+12 -1
View File
@@ -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