mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
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:
1 parent
19b70430a1
commit
c58ccbfc3f
16 files changed
+1317
-78
No files matched your search
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,8 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
},
|
||||
],
|
||||
},
|
||||
permissions: [],
|
||||
grantedCapabilities: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user