fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
This commit is contained in:
LukeGus committed 2026-09-21 14:32:59 -05:00
1 parent 19b70430a1
commit c58ccbfc3f
16 files changed
+1317 -78

No files matched your search

+22
View File
@@ -21,6 +21,10 @@ export interface PluginSummary {
runtimeState: string;
lastError: string | null;
contributes: PluginContributions | null;
/** Capabilities this plugin's manifest declares it may ask for. */
permissions: string[];
/** The subset of `permissions` an admin has actually granted. */
grantedCapabilities: string[];
}
export async function getPlugins(): Promise<PluginSummary[]> {
@@ -36,3 +40,21 @@ export async function setPluginEnabled(
enabled,
});
}
export async function grantPluginCapability(
pluginId: string,
capability: string,
): Promise<void> {
await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, {
capability,
});
}
export async function revokePluginCapability(
pluginId: string,
capability: string,
): Promise<void> {
await rbacApi.delete(
`/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`,
);
}
+31 -1
View File
@@ -3984,7 +3984,37 @@
"pluginToggleFailed": "Failed to change the plugin",
"pluginBuiltIn": "BUILT IN",
"pluginEnabled": "{{name}} enabled",
"pluginDisabled": "{{name}} disabled"
"pluginDisabled": "{{name}} disabled",
"pluginPermissions": "Permissions",
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
"pluginRevokeFailed": "Failed to revoke the capability",
"pluginPermissionHostsRead": "Read hosts",
"pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.",
"pluginPermissionHostsWrite": "Modify hosts",
"pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.",
"pluginPermissionCredentialsUse": "Use stored credentials",
"pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.",
"pluginPermissionSshExec": "Run commands over SSH",
"pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.",
"pluginPermissionSshSftp": "Transfer files over SFTP",
"pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.",
"pluginPermissionStorageOwn": "Store its own data",
"pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.",
"pluginPermissionStorageSecrets": "Store secrets",
"pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.",
"pluginPermissionNetworkOutbound": "Make outbound network requests",
"pluginPermissionNetworkOutboundDesc": "Contact services outside this server.",
"pluginPermissionEventsRead": "Subscribe to server events",
"pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.",
"pluginPermissionNotifySend": "Send notifications",
"pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.",
"pluginPermissionUsersRead": "Read user accounts",
"pluginPermissionUsersReadDesc": "See basic details about accounts on this server.",
"pluginPermissionProcessSidecar": "Run a background process",
"pluginPermissionProcessSidecarDesc": "Start and manage its own helper process."
},
"newUi": {
"sidebar": {
+67 -36
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Puzzle } from "lucide-react";
import { Puzzle, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import { Button } from "@/components/button";
import {
getPlugins,
setPluginEnabled,
@@ -10,6 +11,7 @@ import {
} from "@/api/plugins-api";
import { refreshPluginState } from "@/shell/pluginLoader";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { PluginPermissionsDialog } from "./PluginPermissionsDialog";
export function AdminPluginsSection({
open,
@@ -22,6 +24,11 @@ export function AdminPluginsSection({
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [permissionsTargetId, setPermissionsTargetId] = useState<string | null>(
null,
);
const permissionsTarget =
plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null;
const load = useCallback(async () => {
try {
@@ -57,40 +64,64 @@ export function AdminPluginsSection({
};
return (
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
}
>
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</SettingRow>
))
)}
</AccordionSection>
<>
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
}
>
<div className="flex items-center gap-2">
{plugin.permissions.length > 0 && (
<Button
variant="outline"
size="sm"
className="rounded-none h-7 px-2 text-[10px] font-bold uppercase tracking-widest"
onClick={() => setPermissionsTargetId(plugin.id)}
>
<ShieldCheck className="size-3" />
{t("admin.pluginPermissions")}
</Button>
)}
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</div>
</SettingRow>
))
)}
</AccordionSection>
<PluginPermissionsDialog
plugin={permissionsTarget}
open={permissionsTargetId !== null}
onOpenChange={(next) => {
if (!next) setPermissionsTargetId(null);
}}
onChanged={() => void load()}
/>
</>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldCheck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
grantPluginCapability,
revokePluginCapability,
type PluginSummary,
} from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
/**
* Every permission a plugin's manifest can declare, with the label and
* one-line description shown here. process:transport-owner is left out on
* purpose: it is reserved for first-party plugins and enforced by a hardcoded
* allowlist, not something an admin grants, so showing a toggle for it would
* be misleading. ui.* permissions describe what a plugin contributes to the
* shell rather than a capability the broker gates, but they are still shown
* here since the manifest can declare them and a plugin's grant list should
* not silently hide part of what it asked for.
*/
const LABELED_PERMISSIONS: Array<{
capability: string;
labelKey: string;
descriptionKey: string;
}> = [
{
capability: "hosts.read",
labelKey: "admin.pluginPermissionHostsRead",
descriptionKey: "admin.pluginPermissionHostsReadDesc",
},
{
capability: "hosts.write",
labelKey: "admin.pluginPermissionHostsWrite",
descriptionKey: "admin.pluginPermissionHostsWriteDesc",
},
{
capability: "credentials.use",
labelKey: "admin.pluginPermissionCredentialsUse",
descriptionKey: "admin.pluginPermissionCredentialsUseDesc",
},
{
capability: "ssh.exec",
labelKey: "admin.pluginPermissionSshExec",
descriptionKey: "admin.pluginPermissionSshExecDesc",
},
{
capability: "ssh.sftp",
labelKey: "admin.pluginPermissionSshSftp",
descriptionKey: "admin.pluginPermissionSshSftpDesc",
},
{
capability: "storage.own",
labelKey: "admin.pluginPermissionStorageOwn",
descriptionKey: "admin.pluginPermissionStorageOwnDesc",
},
{
capability: "storage.secrets",
labelKey: "admin.pluginPermissionStorageSecrets",
descriptionKey: "admin.pluginPermissionStorageSecretsDesc",
},
{
capability: "network.outbound",
labelKey: "admin.pluginPermissionNetworkOutbound",
descriptionKey: "admin.pluginPermissionNetworkOutboundDesc",
},
{
capability: "events.read",
labelKey: "admin.pluginPermissionEventsRead",
descriptionKey: "admin.pluginPermissionEventsReadDesc",
},
{
capability: "notify.send",
labelKey: "admin.pluginPermissionNotifySend",
descriptionKey: "admin.pluginPermissionNotifySendDesc",
},
{
capability: "users.read",
labelKey: "admin.pluginPermissionUsersRead",
descriptionKey: "admin.pluginPermissionUsersReadDesc",
},
{
capability: "process.sidecar",
labelKey: "admin.pluginPermissionProcessSidecar",
descriptionKey: "admin.pluginPermissionProcessSidecarDesc",
},
];
export function PluginPermissionsDialog({
plugin,
open,
onOpenChange,
onChanged,
}: {
plugin: PluginSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null);
if (!plugin) return null;
const rows = LABELED_PERMISSIONS.filter((row) =>
plugin.permissions.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities);
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
try {
if (isGranted) {
await revokePluginCapability(plugin.id, capability);
} else {
await grantPluginCapability(plugin.id, capability);
}
onChanged();
} catch {
toast.error(
isGranted
? t("admin.pluginRevokeFailed")
: t("admin.pluginGrantFailed"),
);
} finally {
setBusy(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<ShieldCheck className="size-4 text-accent-brand" />
{t("admin.pluginPermissions")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground">
{plugin.name}
</DialogDescription>
</DialogHeader>
<div className="py-3">
{rows.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{t("admin.pluginPermissionsNone")}
</p>
) : (
<div className="border border-border overflow-hidden">
{rows.map((row, i) => {
const isGranted = granted.has(row.capability);
return (
<div
key={row.capability}
className={`flex items-center justify-between gap-3 px-3 py-3 ${
i < rows.length - 1 ? "border-b border-border" : ""
}`}
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-xs font-semibold">
{t(row.labelKey)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(row.descriptionKey)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-0.5">
{isGranted
? t("admin.pluginPermissionGranted")
: t("admin.pluginPermissionNotGranted")}
</span>
</div>
<AdminToggle
on={isGranted}
onToggle={() => void toggle(row.capability, isGranted)}
disabled={busy === row.capability}
/>
</div>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -43,6 +43,8 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
},
],
},
permissions: [],
grantedCapabilities: [],
...overrides,
};
}