mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
fix: harden the plugin runtime after the 2.9.0 security review
This commit is contained in:
1 parent
2961297989
commit
c6a44b7ff3
69 files changed
+4613
-312
No files matched your search
@@ -3,20 +3,23 @@
|
||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||
// bearer header, and nothing here is secret. It only ever serves files from
|
||||
// the two directories a plugin ships for the browser, so a request can never
|
||||
// reach the plugin's backend bundle, migrations or anything outside it.
|
||||
// reach the plugin's backend bundle, migrations or anything outside it, and
|
||||
// only for a plugin that is running.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
DEFAULT_BACKEND_ENTRY,
|
||||
DEFAULT_FRONTEND_ENTRY,
|
||||
DEFAULT_LOCALES_DIR,
|
||||
type PluginManifest,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
import type { LoadedPlugin } from "./loader.js";
|
||||
|
||||
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
|
||||
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest"> &
|
||||
Partial<Pick<LoadedPlugin, "state">>;
|
||||
|
||||
export interface PluginFrontendInfo {
|
||||
frontend: boolean;
|
||||
@@ -135,9 +138,41 @@ export function resolvePluginAsset(
|
||||
|
||||
const file = path.resolve(root, rest);
|
||||
if (!isInside(root, file) || !fileExists(file)) return null;
|
||||
|
||||
// The text check above cannot see a symlink, so the real paths have to
|
||||
// agree too: a link inside dist/ must not reach a file outside the plugin.
|
||||
let real: string;
|
||||
try {
|
||||
real = fs.realpathSync(file);
|
||||
if (!isInside(fs.realpathSync(plugin.dir), real)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (isServerOnly(plugin, real)) return null;
|
||||
return file;
|
||||
}
|
||||
|
||||
/** The backend entry and the migrations, wherever the manifest put them. */
|
||||
function isServerOnly(plugin: AssetPlugin, real: string): boolean {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const backend = path.resolve(
|
||||
plugin.dir,
|
||||
manifest.backend ?? DEFAULT_BACKEND_ENTRY,
|
||||
);
|
||||
const candidates = [backend, `${backend}.map`];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.realpathSync(candidate) === real) return true;
|
||||
} catch {
|
||||
// Not there.
|
||||
}
|
||||
}
|
||||
const segments = path
|
||||
.relative(fs.realpathSync(plugin.dir), real)
|
||||
.split(path.sep);
|
||||
return segments.includes("migrations");
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
@@ -182,7 +217,7 @@ export function createPluginAssetsRouter(
|
||||
* 200:
|
||||
* description: The file.
|
||||
* 404:
|
||||
* description: Unknown plugin or file.
|
||||
* description: Unknown or stopped plugin, or no such file.
|
||||
*/
|
||||
router.get(
|
||||
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
|
||||
@@ -190,7 +225,11 @@ export function createPluginAssetsRouter(
|
||||
const params = req.params as unknown as Record<string, string>;
|
||||
const pluginId = params[0];
|
||||
const plugin = getPlugin(pluginId);
|
||||
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
|
||||
// A disabled or failed plugin serves nothing, so its bundle cannot be
|
||||
// loaded around the shell's own enabled check.
|
||||
const running = !!plugin && (!plugin.state || plugin.state === "active");
|
||||
const file =
|
||||
plugin && running ? resolvePluginAsset(plugin, params[1]) : null;
|
||||
|
||||
// Loaded as a module from Electron's file:// page and the Vite dev
|
||||
// server, so any origin may read it. No credentials are involved.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
export interface RegistrationConflict {
|
||||
kind: "service" | "registry" | "permission" | "ws";
|
||||
kind: "service" | "registry" | "permission" | "ws" | "id";
|
||||
pluginId: string;
|
||||
/** Who already held it, when known. */
|
||||
heldBy?: string;
|
||||
|
||||
@@ -26,9 +26,8 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import ssh2 from "ssh2";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
|
||||
@@ -293,7 +292,7 @@ export function createPluginCredentials({
|
||||
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
if (!declared.includes("auth:provide")) {
|
||||
throw new PluginCapabilityError(pluginId, "auth:provide");
|
||||
throw capabilityRefused(pluginId, "auth:provide");
|
||||
}
|
||||
if (!manifest.contributes?.auth?.secretSchemes?.includes(scheme)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -21,8 +21,7 @@ import type {
|
||||
PluginShareableRole,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||
import { hostSessionStatus } from "../hosts/host-session-status.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import { hostStatusService } from "../hosts/status/host-status-service.js";
|
||||
@@ -65,8 +64,56 @@ function toSummary(host: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Host fields that carry secret material. Stripped from anything ctx.hosts or
|
||||
* ctx.ssh hands a plugin, unless the plugin holds credentials:read.
|
||||
*/
|
||||
export const HOST_SECRET_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"privateKey",
|
||||
"passphrase",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"vaultToken",
|
||||
] as const;
|
||||
|
||||
/** A copy of a host with every secret field removed, nested ones included. */
|
||||
export function redactHostSecrets<T extends Record<string, unknown>>(
|
||||
host: T,
|
||||
): T {
|
||||
const copy: Record<string, unknown> = { ...host };
|
||||
for (const field of HOST_SECRET_FIELDS) delete copy[field];
|
||||
const terminalConfig = copy.terminalConfig;
|
||||
if (terminalConfig && typeof terminalConfig === "object") {
|
||||
const { sudoPassword: _sudo, ...rest } = terminalConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
copy.terminalConfig = rest;
|
||||
}
|
||||
if (Array.isArray(copy.socks5ProxyChain)) {
|
||||
copy.socks5ProxyChain = copy.socks5ProxyChain.map((hop) =>
|
||||
hop && typeof hop === "object"
|
||||
? redactHostSecrets(hop as Record<string, unknown>)
|
||||
: hop,
|
||||
);
|
||||
}
|
||||
return copy as T;
|
||||
}
|
||||
|
||||
/** Columns that say who a host belongs to. ctx.hosts.update never moves them. */
|
||||
const PROTECTED_HOST_FIELDS = new Set(["id", "userId", "syncId"]);
|
||||
|
||||
function toRecord(host: Record<string, unknown>): PluginHostRecord {
|
||||
return {
|
||||
return redactHostSecrets({
|
||||
id: host.id as number,
|
||||
userId: host.userId as string,
|
||||
name: (host.name as string | null) ?? null,
|
||||
@@ -85,7 +132,7 @@ function toRecord(host: Record<string, unknown>): PluginHostRecord {
|
||||
createdAt: (host.createdAt as string | null) ?? null,
|
||||
updatedAt: (host.updatedAt as string | null) ?? null,
|
||||
...host,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function actingUser(): string {
|
||||
@@ -105,12 +152,27 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
|
||||
const requireReadSync = () => {
|
||||
if (!declared.includes("hosts:read")) {
|
||||
throw new PluginCapabilityError(pluginId, "hosts:read");
|
||||
throw capabilityRefused(pluginId, "hosts:read");
|
||||
}
|
||||
};
|
||||
const requireWrite = () =>
|
||||
assertCapability(pluginId, "hosts:write", declared);
|
||||
|
||||
// With an actor, a host the actor cannot reach is out of bounds. With none,
|
||||
// it is the plugin's own background work (a poller, a guacd session).
|
||||
const actorMayReach = async (hostId: number): Promise<boolean> => {
|
||||
const actor = getActor();
|
||||
if (!actor) return true;
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
actor,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
return access.hasAccess;
|
||||
};
|
||||
|
||||
return {
|
||||
list: async () => {
|
||||
await requireRead();
|
||||
@@ -235,13 +297,18 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const allowed = Object.fromEntries(
|
||||
Object.entries(patch as Record<string, unknown>).filter(
|
||||
([field]) => !PROTECTED_HOST_FIELDS.has(field),
|
||||
),
|
||||
) as PluginHostUpdateInput;
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const updated =
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
hostId,
|
||||
patch,
|
||||
allowed,
|
||||
);
|
||||
await audit("hosts_update", `host ${hostId}`, {
|
||||
success: updated !== null,
|
||||
@@ -287,6 +354,9 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
await import("../database/repositories/factory.js");
|
||||
const rows =
|
||||
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
||||
await audit("hosts_list_owned", `${rows.length} host(s)`, {
|
||||
success: true,
|
||||
});
|
||||
return rows.map((row) =>
|
||||
toRecord(row as unknown as Record<string, unknown>),
|
||||
);
|
||||
@@ -437,36 +507,55 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
},
|
||||
|
||||
// Synchronous so a transport can call it from an ssh2 "ready" handler, so
|
||||
// only the declaration is checked here, like ctx.http.router.
|
||||
// only the declaration is checked up front, like ctx.http.router. The
|
||||
// session only counts once the actor's access to the host is confirmed.
|
||||
trackSession: (hostId: number) => {
|
||||
if (!declared.includes("hosts:read")) {
|
||||
throw new PluginCapabilityError(pluginId, "hosts:read");
|
||||
throw capabilityRefused(pluginId, "hosts:read");
|
||||
}
|
||||
return hostSessionStatus.register(hostId);
|
||||
let release: (() => void) | null = null;
|
||||
let stopped = false;
|
||||
void actorMayReach(hostId)
|
||||
.then((allowed) => {
|
||||
if (allowed && !stopped) release = hostSessionStatus.register(hostId);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
stopped = true;
|
||||
release?.();
|
||||
release = null;
|
||||
};
|
||||
},
|
||||
|
||||
recordActivity: async (hostId, type, hostName) => {
|
||||
await requireRead();
|
||||
const userId = actingUser();
|
||||
if (!(await actorMayReach(hostId))) return;
|
||||
const { recordRecentActivity } =
|
||||
await import("../services/recent-activity.js");
|
||||
await recordRecentActivity(userId, { type, hostId, hostName });
|
||||
},
|
||||
|
||||
// Synchronous and not audited, like trackSession: pollers call these on
|
||||
// every sample.
|
||||
// Not audited: pollers call these on every sample.
|
||||
status: {
|
||||
get: (hostId) => {
|
||||
requireReadSync();
|
||||
get: async (hostId) => {
|
||||
await requireRead();
|
||||
if (!(await actorMayReach(hostId))) return null;
|
||||
return hostStatusService.get(hostId);
|
||||
},
|
||||
check: async (hostId) => {
|
||||
await requireRead();
|
||||
if (!(await actorMayReach(hostId))) return null;
|
||||
return hostStatusService.check(hostId);
|
||||
},
|
||||
// Synchronous for the same reason as trackSession.
|
||||
reportLogin: (hostId, outcome) => {
|
||||
requireReadSync();
|
||||
hostStatusService.reportLogin(hostId, outcome);
|
||||
void actorMayReach(hostId)
|
||||
.then((allowed) => {
|
||||
if (allowed) hostStatusService.reportLogin(hostId, outcome);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
registerPort: (connectionType, resolve) => {
|
||||
requireReadSync();
|
||||
|
||||
@@ -88,10 +88,10 @@ export function createPluginProcess(deps: Deps): PluginProcess {
|
||||
};
|
||||
|
||||
const run: PluginProcess["run"] = (file, args, options = {}) =>
|
||||
audited("process_run", `ran ${path.basename(file)}`, async () => {
|
||||
audited("process_run", `ran ${path.basename(String(file))}`, async () => {
|
||||
const child = spawn(file, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env ?? {}) },
|
||||
env: { ...baseEnvironment(), ...(options.env ?? {}) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
});
|
||||
@@ -210,3 +210,46 @@ export function createPluginProcess(deps: Deps): PluginProcess {
|
||||
|
||||
return { run, ensureBinary };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plugin's program inherits from the server: enough to find binaries,
|
||||
* a home and temp folder, the locale and the outbound proxy. Never the rest,
|
||||
* which holds the JWT secret, database keys and OIDC client secrets.
|
||||
*/
|
||||
const INHERITED_ENV = [
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"TMPDIR",
|
||||
"SystemRoot",
|
||||
"windir",
|
||||
"ComSpec",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"TZ",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
];
|
||||
|
||||
export function baseEnvironment(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const inherited: NodeJS.ProcessEnv = {};
|
||||
for (const name of INHERITED_ENV) {
|
||||
if (env[name] !== undefined) inherited[name] = env[name];
|
||||
}
|
||||
return inherited;
|
||||
}
|
||||
@@ -14,13 +14,17 @@ import type {
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { PluginSshInteractionError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
PluginCapabilityError,
|
||||
PluginSshInteractionError,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
assertCapability,
|
||||
capabilityRefused,
|
||||
hasCachedCapability,
|
||||
hasCapability,
|
||||
warmPluginGrants,
|
||||
} from "./permissions.js";
|
||||
import { getActor, getActorSessionId } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { redactHostSecrets } from "./ctx-hosts.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listSshAuthProviders,
|
||||
@@ -74,16 +78,16 @@ function profileOf(options?: { profile?: string }): SshConnectProfile {
|
||||
}
|
||||
|
||||
/**
|
||||
* The user a connection runs as: the request's actor, or for background work
|
||||
* on a resolved host, that host's owner. A bare host id with no actor has
|
||||
* nobody to resolve it for.
|
||||
* The user a connection runs as: the actor, or for a host core resolved
|
||||
* earlier, the user it was resolved for. A host object's own userId is plugin
|
||||
* data and is never believed, or a plugin could borrow another user's jump
|
||||
* hosts and stored credentials by naming them.
|
||||
*/
|
||||
function actingUser(host: number | PluginSshHost): string {
|
||||
const actor = getActor();
|
||||
function actingUser(resolvedFor?: string): string {
|
||||
const actor = getActor() ?? resolvedFor;
|
||||
if (actor) return actor;
|
||||
if (typeof host === "object" && host.userId) return host.userId;
|
||||
throw new Error(
|
||||
"ctx.ssh needs an acting user: call it inside a request, ctx.asUser, or pass a resolved host",
|
||||
"ctx.ssh needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +123,47 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
}
|
||||
};
|
||||
|
||||
// Hosts core resolved, keyed by the redacted copy the plugin was given, so
|
||||
// handing that copy back to connect still connects with the real secrets.
|
||||
const resolvedHosts = new WeakMap<
|
||||
object,
|
||||
{ host: SshConnectHost; userId: string }
|
||||
>();
|
||||
|
||||
const remember = (key: object, host: SshConnectHost, userId: string) =>
|
||||
resolvedHosts.set(key, { host, userId });
|
||||
|
||||
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
|
||||
const redacted = redactHostSecrets(
|
||||
host as unknown as Record<string, unknown>,
|
||||
) as unknown as PluginSshHost;
|
||||
remember(redacted, host, userId);
|
||||
return redacted;
|
||||
};
|
||||
|
||||
/** The acting user for a call about this host. */
|
||||
const userFor = (host: number | PluginSshHost | undefined): string =>
|
||||
actingUser(
|
||||
host && typeof host === "object"
|
||||
? resolvedHosts.get(host)?.userId
|
||||
: undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* A host for core's pipeline. An id stays an id (core resolves it with an
|
||||
* access check). An object core handed out maps back to what core resolved;
|
||||
* any other object is the plugin's own, so it runs as the actor.
|
||||
*/
|
||||
const toConnectHost = (
|
||||
host: number | PluginSshHost,
|
||||
userId: string,
|
||||
): number | SshConnectHost => {
|
||||
if (typeof host === "number") return host;
|
||||
const known = resolvedHosts.get(host);
|
||||
if (known) return known.host;
|
||||
return { ...(host as unknown as SshConnectHost), userId };
|
||||
};
|
||||
|
||||
/** Checks, connects and audits one new connection. */
|
||||
const connectOnce = async (
|
||||
host: number | PluginSshHost,
|
||||
@@ -134,10 +179,19 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const userId = actingUser(host);
|
||||
let userId: string;
|
||||
try {
|
||||
userId = userFor(host);
|
||||
} catch (error) {
|
||||
await audit("ssh_connect", describeHost(host), {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const { connectHost } = await import("../hosts/connect/connect-host.js");
|
||||
try {
|
||||
const connection = await connectHost(host as number | SshConnectHost, {
|
||||
const connection = await connectHost(toConnectHost(host, userId), {
|
||||
userId,
|
||||
purpose: purposeOf(options),
|
||||
profile: profileOf(options),
|
||||
@@ -156,7 +210,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
connection.client.once("close", () => open.delete(dispose));
|
||||
return {
|
||||
...connection,
|
||||
host: connection.host as unknown as PluginSshHost,
|
||||
host: handOut(connection.host as SshConnectHost, userId),
|
||||
dispose,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -188,10 +242,12 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
await checkSsh(true);
|
||||
const { resolveConnectHost } =
|
||||
await import("../hosts/connect/connect-host.js");
|
||||
const userId = userFor(host);
|
||||
const resolved = await resolveConnectHost(
|
||||
host as number | SshConnectHost,
|
||||
actingUser(host),
|
||||
toConnectHost(host, userId),
|
||||
userId,
|
||||
);
|
||||
remember(resolved, resolved, userId);
|
||||
const key = poolKey(options.pool, resolved as PluginSshHost);
|
||||
poolKeys.set(key, resolved.id);
|
||||
const { withConnection } =
|
||||
@@ -206,7 +262,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
|
||||
jumpChain: async (jumpHosts, chainOptions) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(chainOptions?.forHost ?? 0);
|
||||
const userId = userFor(chainOptions?.forHost);
|
||||
const { createJumpHostChain } =
|
||||
await import("../hosts/jump-host-chain.js");
|
||||
const client = await createJumpHostChain(jumpHosts, userId);
|
||||
@@ -227,12 +283,16 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
client.once("close", () => open.delete(dispose));
|
||||
// A jump chain ends at a forwarding client, not a single resolved SSH
|
||||
// host: report the caller's own host if it gave one, else the last hop.
|
||||
const host: PluginSshHost = chainOptions?.forHost ?? {
|
||||
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
};
|
||||
const host: PluginSshHost = chainOptions?.forHost
|
||||
? (redactHostSecrets(
|
||||
chainOptions.forHost as unknown as Record<string, unknown>,
|
||||
) as unknown as PluginSshHost)
|
||||
: {
|
||||
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
};
|
||||
return { client: client as never, jumpClient: null, host, dispose };
|
||||
},
|
||||
|
||||
@@ -253,7 +313,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
|
||||
resolveHost: async (hostId, options) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(hostId);
|
||||
const userId = actingUser();
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../hosts/host-resolver.js");
|
||||
const resolved = options?.syncId
|
||||
@@ -263,15 +323,30 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
success: !!resolved,
|
||||
errorMessage: resolved ? undefined : "Host not found",
|
||||
});
|
||||
return (resolved as unknown as PluginSshHost | null) ?? null;
|
||||
if (!resolved) return null;
|
||||
// The secrets only go to a plugin that declared it reads them.
|
||||
if (await hasCapability(pluginId, "credentials:read", declared)) {
|
||||
await audit("credentials_read", describeHost(hostId), {
|
||||
success: true,
|
||||
});
|
||||
const full = resolved as unknown as SshConnectHost;
|
||||
remember(full, full, userId);
|
||||
return full as unknown as PluginSshHost;
|
||||
}
|
||||
return handOut(resolved as unknown as SshConnectHost, userId);
|
||||
},
|
||||
|
||||
// The config it returns carries the password and private key, so it
|
||||
// needs credentials:read on top of the connect pair.
|
||||
prepare: async (host, options) => {
|
||||
await checkSsh(true);
|
||||
await assertCapability(pluginId, "credentials:read", declared);
|
||||
const userId = userFor(host);
|
||||
const target = toConnectHost(host, userId) as SshConnectHost;
|
||||
const { buildConnectConfig } =
|
||||
await import("../hosts/connect/build-connect-config.js");
|
||||
const built = await buildConnectConfig(host as SshConnectHost, {
|
||||
userId: actingUser(host),
|
||||
const built = await buildConnectConfig(target, {
|
||||
userId,
|
||||
purpose: purposeOf(options),
|
||||
profile: profileOf(options),
|
||||
client: options.client as never,
|
||||
@@ -286,7 +361,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
built.outcome.status === "ready" ? undefined : built.outcome.message,
|
||||
});
|
||||
const provider = built.provider;
|
||||
const target = host as SshConnectHost;
|
||||
return {
|
||||
config: built.config,
|
||||
outcome: built.outcome,
|
||||
@@ -302,14 +376,25 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
},
|
||||
|
||||
openTransport: async (host, config, transportOptions) => {
|
||||
await checkSsh(false);
|
||||
await checkSsh(true);
|
||||
const target = toConnectHost(host, userFor(host)) as SshConnectHost;
|
||||
const { openSshTransport } =
|
||||
await import("../hosts/connect/transport.js");
|
||||
const opened = await openSshTransport(
|
||||
host as SshConnectHost,
|
||||
config as MutableConnectConfig,
|
||||
transportOptions,
|
||||
);
|
||||
let opened: Awaited<ReturnType<typeof openSshTransport>>;
|
||||
try {
|
||||
opened = await openSshTransport(
|
||||
target,
|
||||
config as MutableConnectConfig,
|
||||
transportOptions,
|
||||
);
|
||||
} catch (error) {
|
||||
await audit("ssh_open_transport", describeHost(host), {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
await audit("ssh_open_transport", describeHost(host), { success: true });
|
||||
if (opened.jumpClient) {
|
||||
const jumpClient = opened.jumpClient;
|
||||
const dispose = () => {
|
||||
@@ -324,8 +409,22 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
|
||||
startInteraction: async (interaction, request) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(request.hostId);
|
||||
const userId = actingUser();
|
||||
ensureCoreSshAuthProviders();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
request.hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
await audit("ssh_start_interaction", describeHost(request.hostId), {
|
||||
success: false,
|
||||
errorMessage: "Host not found",
|
||||
});
|
||||
throw new PluginSshInteractionError("Host not found");
|
||||
}
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
@@ -367,7 +466,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
|
||||
cancelInteraction: async (interaction, request) => {
|
||||
await checkSsh(false);
|
||||
const userId = actingUser(request.hostId ?? 0);
|
||||
const userId = actingUser();
|
||||
ensureCoreSshAuthProviders();
|
||||
for (const provider of listSshAuthProviders()) {
|
||||
if (provider.interaction !== interaction) continue;
|
||||
@@ -467,7 +566,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
field: string,
|
||||
) => {
|
||||
if (!declared.includes("auth:provide")) {
|
||||
throw new PluginCapabilityError(pluginId, "auth:provide");
|
||||
throw capabilityRefused(pluginId, "auth:provide");
|
||||
}
|
||||
if (!list?.includes(id)) {
|
||||
throw new Error(
|
||||
@@ -477,6 +576,10 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
};
|
||||
|
||||
const granted = () => assertCapability(pluginId, "auth:provide", declared);
|
||||
// ssh2 calls some hooks synchronously, so those answer from the grant
|
||||
// cache and do nothing until the grant is confirmed.
|
||||
const grantedNow = () =>
|
||||
hasCachedCapability(pluginId, "auth:provide", declared);
|
||||
|
||||
const record = (action: string, id: string) =>
|
||||
void audit(action, `${pluginId} registered ${id}`, { success: true });
|
||||
@@ -499,8 +602,22 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
if (provider.onBanner) {
|
||||
const onBanner = provider.onBanner;
|
||||
wrapped.onBanner = (banner, host, env) =>
|
||||
onBanner(banner, host as PluginSshHost, env as never) as never;
|
||||
grantedNow()
|
||||
? (onBanner(banner, host as PluginSshHost, env as never) as never)
|
||||
: (undefined as never);
|
||||
}
|
||||
if (provider.onAuthFailed) {
|
||||
const onAuthFailed = provider.onAuthFailed;
|
||||
wrapped.onAuthFailed = (host, env, context) =>
|
||||
grantedNow()
|
||||
? (onAuthFailed(
|
||||
host as PluginSshHost,
|
||||
env as never,
|
||||
context as never,
|
||||
) as never)
|
||||
: undefined;
|
||||
}
|
||||
warmPluginGrants(pluginId);
|
||||
ensureCoreSshAuthProviders();
|
||||
const disposers = [registerSshAuthProvider(wrapped)];
|
||||
if (provider.onKeyboardInteractive) {
|
||||
@@ -510,7 +627,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
id: `${pluginId}:${provider.type}`,
|
||||
pluginId,
|
||||
detect: (round, host) =>
|
||||
host.authType === provider.type
|
||||
host.authType === provider.type && grantedNow()
|
||||
? (detect(round, host as PluginSshHost) as never)
|
||||
: null,
|
||||
}),
|
||||
@@ -529,8 +646,9 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
handler.id,
|
||||
"keyboardInteractive",
|
||||
);
|
||||
// Synchronous hooks inside ssh2's callback, so only the declaration is
|
||||
// checked, like ctx.http.router. Deactivate removes the handler.
|
||||
// Synchronous hooks inside ssh2's callback, so they answer from the
|
||||
// grant cache. Deactivate removes the handler.
|
||||
warmPluginGrants(pluginId);
|
||||
const settingsFor = (host: SshConnectHost): Record<string, unknown> =>
|
||||
host.pluginSettings?.[pluginId] ?? {};
|
||||
ensureCoreSshAuthProviders();
|
||||
@@ -538,6 +656,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
id: handler.id,
|
||||
pluginId,
|
||||
detect: (round, host) => {
|
||||
if (!grantedNow()) return null;
|
||||
const detected = handler.detect(
|
||||
round,
|
||||
host as PluginSshHost,
|
||||
@@ -551,6 +670,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
},
|
||||
autoAnswerPasswords: handler.autoAnswerPasswords
|
||||
? (host) =>
|
||||
grantedNow() &&
|
||||
handler.autoAnswerPasswords!(
|
||||
host as PluginSshHost,
|
||||
settingsFor(host),
|
||||
|
||||
+51
-36
@@ -27,7 +27,11 @@ import * as serviceRegistry from "./service-registry.js";
|
||||
import type { ServiceRegistration } from "./service-registry.js";
|
||||
import * as secretRegistry from "./secret-registry.js";
|
||||
import type { SecretRegistration } from "./secret-registry.js";
|
||||
import { assertCapability, hasCapability } from "./permissions.js";
|
||||
import {
|
||||
assertCapability,
|
||||
capabilityRefused,
|
||||
hasCapability,
|
||||
} from "./permissions.js";
|
||||
import { getActor, runAsActor } from "./actor.js";
|
||||
import { DisposableBag } from "./disposables.js";
|
||||
import {
|
||||
@@ -40,7 +44,6 @@ import { resolvePermission } from "./rbac.js";
|
||||
import * as pluginSettings from "./settings.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
PluginCapabilityError,
|
||||
type PluginContext,
|
||||
type PluginModule,
|
||||
type PluginOpenIsolatedWindowRequest,
|
||||
@@ -110,8 +113,9 @@ interface AuditOptions {
|
||||
/**
|
||||
* Wraps a privileged function in the capability check and the audit line.
|
||||
*
|
||||
* The check runs before the call and the audit after, so a denied call is
|
||||
* still recorded: "tried and was refused" is exactly the thing worth seeing.
|
||||
* The check runs before the call and the audit after. A denied call is still
|
||||
* recorded, by assertCapability: "tried and was refused" is exactly the thing
|
||||
* worth seeing.
|
||||
*/
|
||||
function guarded<Args extends unknown[], Result>(
|
||||
manifest: PluginManifest,
|
||||
@@ -120,17 +124,13 @@ function guarded<Args extends unknown[], Result>(
|
||||
options: AuditOptions,
|
||||
): (...args: Args) => Promise<Result> {
|
||||
return async (...args: Args): Promise<Result> => {
|
||||
const pluginId = manifest.id;
|
||||
|
||||
try {
|
||||
await assertCapability(pluginId, capability, manifest.capabilities);
|
||||
} catch (error) {
|
||||
await writeAudit(manifest, options, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
// A refusal is audited inside assertCapability, under this action.
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
capability,
|
||||
manifest.capabilities,
|
||||
options.action,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
@@ -191,6 +191,26 @@ export function createPluginContext(
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginSettingsRepository();
|
||||
};
|
||||
// A user named by the plugin instead of the ambient actor. Honoured, like
|
||||
// ctx.asUser, and audited the same way.
|
||||
const namedUser = (
|
||||
userId: string | undefined,
|
||||
via: string,
|
||||
): string | undefined => {
|
||||
if (!userId) return getActor();
|
||||
if (userId !== getActor()) {
|
||||
void writeAudit(
|
||||
manifest,
|
||||
{
|
||||
action: "as_user",
|
||||
details: () => `${pluginId} acted as ${userId} through ${via}`,
|
||||
},
|
||||
{ success: true },
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
const secretOwner = (): string => {
|
||||
const actor = getActor();
|
||||
if (!actor) {
|
||||
@@ -384,19 +404,12 @@ export function createPluginContext(
|
||||
);
|
||||
|
||||
const capabilitiesRequire = async (capability: string) => {
|
||||
try {
|
||||
await assertCapability(pluginId, capability, declared);
|
||||
} catch (error) {
|
||||
await writeAudit(
|
||||
manifest,
|
||||
{ action: "capability_require", details: () => capability },
|
||||
{
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
await assertCapability(
|
||||
pluginId,
|
||||
capability,
|
||||
declared,
|
||||
"capability_require",
|
||||
);
|
||||
await writeAudit(
|
||||
manifest,
|
||||
{ action: "capability_require", details: () => capability },
|
||||
@@ -551,10 +564,11 @@ export function createPluginContext(
|
||||
service,
|
||||
pluginId,
|
||||
{
|
||||
// A caller-supplied userId is only honoured when it matches the
|
||||
// actor; otherwise the ambient actor wins. A plugin cannot widen
|
||||
// its reach by naming someone else here.
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
// Naming a user other than the actor is the same thing as
|
||||
// ctx.asUser, so it gets the same audit line.
|
||||
resolveUserId: () =>
|
||||
namedUser(options?.userId, `service ${service}`),
|
||||
nameUser: (userId) => namedUser(userId, `service ${service}`),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
},
|
||||
@@ -667,7 +681,8 @@ export function createPluginContext(
|
||||
|
||||
getShared: (providerPluginId, key, options) =>
|
||||
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
resolveUserId: () =>
|
||||
namedUser(options?.userId, `shared secret ${providerPluginId}`),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeSecretAudit(entry),
|
||||
}),
|
||||
@@ -679,7 +694,7 @@ export function createPluginContext(
|
||||
// and router() has to be synchronous so a plugin can register routes
|
||||
// inline in activate. http.ts re-checks on every request.
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||
throw capabilityRefused(pluginId, "network:serve");
|
||||
}
|
||||
|
||||
const router = createPluginRouter({
|
||||
@@ -703,7 +718,7 @@ export function createPluginContext(
|
||||
ws: {
|
||||
route: (path, wsHandler, options) => {
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||
throw capabilityRefused(pluginId, "network:serve");
|
||||
}
|
||||
const dispose = registerPluginWsRoute(
|
||||
pluginId,
|
||||
@@ -717,7 +732,7 @@ export function createPluginContext(
|
||||
|
||||
upgrade: (path, upgradeHandler, options) => {
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||
throw capabilityRefused(pluginId, "network:serve");
|
||||
}
|
||||
const dispose = registerPluginWsUpgrade(
|
||||
pluginId,
|
||||
|
||||
+90
-17
@@ -12,8 +12,8 @@ import { pluginLogger } from "../utils/logger.js";
|
||||
import { resolveDatabaseDialect } from "../database/db/dialect.js";
|
||||
import type { DatabaseDialect } from "../database/db/dialect.js";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import { prefixedTableName } from "@termix/plugin-sdk/db";
|
||||
import { buildTable, dropTableSql } from "./table-builder.js";
|
||||
import { prefixedTableName, tablePrefix } from "@termix/plugin-sdk/db";
|
||||
import { buildTable } from "./table-builder.js";
|
||||
import {
|
||||
applyPluginMigrations,
|
||||
readMigrations,
|
||||
@@ -88,10 +88,9 @@ async function execute(statements: string[]): Promise<void> {
|
||||
/**
|
||||
* Builds the runner that applies migrations against the live database.
|
||||
*
|
||||
* SQLite gets a transaction; the client-server engines do not, because MySQL
|
||||
* commits implicitly on DDL so a "transaction" there would only be a promise
|
||||
* the engine does not keep. A half-applied migration is recorded statement by
|
||||
* statement either way, so a retry resumes rather than repeating.
|
||||
* No transaction on any engine: MySQL commits implicitly on DDL, and each
|
||||
* migration is recorded once it has run, so a retry after a failure starts
|
||||
* at the migration that failed.
|
||||
*/
|
||||
async function createMigrationRunner(
|
||||
dialect: DatabaseDialect = resolveDatabaseDialect(),
|
||||
@@ -126,37 +125,59 @@ async function createMigrationRunner(
|
||||
export async function migratePlugin(
|
||||
pluginId: string,
|
||||
pluginDir: string,
|
||||
dialect: DatabaseDialect = resolveDatabaseDialect(),
|
||||
options: { bundled?: boolean; dialect?: DatabaseDialect } = {},
|
||||
): Promise<string[]> {
|
||||
const dialect = options.dialect ?? resolveDatabaseDialect();
|
||||
if (readMigrations(pluginDir, dialect).length === 0) return [];
|
||||
|
||||
const runner = await createMigrationRunner(dialect);
|
||||
return applyPluginMigrations(pluginId, pluginDir, runner);
|
||||
return applyPluginMigrations(pluginId, pluginDir, runner, {
|
||||
bundled: options.bundled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything a plugin owns.
|
||||
*
|
||||
* Tables go in reverse registration order so a table referenced by a later one
|
||||
* is dropped last. Disabling a plugin never calls this: data outlives being
|
||||
* turned off, and only an explicit uninstall throws it away.
|
||||
* Tables are found in the database's own catalog by the plugin's prefix, not
|
||||
* from what it registered this boot, so a plugin that never activated since
|
||||
* the restart still loses its tables. A longer prefix that belongs to another
|
||||
* known plugin is left alone. Disabling a plugin never calls this: data
|
||||
* outlives being turned off, and only an explicit uninstall throws it away.
|
||||
* Role permissions stay, so a role keeps them if the plugin comes back.
|
||||
*/
|
||||
export async function removePluginData(
|
||||
pluginId: string,
|
||||
dialect: DatabaseDialect = resolveDatabaseDialect(),
|
||||
options: { knownPluginIds?: string[]; dialect?: DatabaseDialect } = {},
|
||||
): Promise<{ tables: string[]; kvKeys: number; migrations: number }> {
|
||||
const definitions = listTables(pluginId);
|
||||
const dropped: string[] = [];
|
||||
const dialect = options.dialect ?? resolveDatabaseDialect();
|
||||
const tables = ownedTableNames(
|
||||
pluginId,
|
||||
await listDatabaseTables(dialect),
|
||||
options.knownPluginIds ?? [],
|
||||
);
|
||||
|
||||
for (const definition of [...definitions].reverse()) {
|
||||
await execute([dropTableSql(dialect, pluginId, definition)]);
|
||||
dropped.push(prefixedTableName(pluginId, definition.name));
|
||||
// Registered order reversed first, so a referencing table goes before the
|
||||
// one it points at; anything else after.
|
||||
const registeredOrder = listTables(pluginId)
|
||||
.map((definition) => prefixedTableName(pluginId, definition.name))
|
||||
.reverse();
|
||||
const ordered = [
|
||||
...registeredOrder.filter((table) => tables.includes(table)),
|
||||
...tables.filter((table) => !registeredOrder.includes(table)),
|
||||
];
|
||||
|
||||
const dropped: string[] = [];
|
||||
for (const table of ordered) {
|
||||
await execute([dropTableByName(dialect, table)]);
|
||||
dropped.push(table);
|
||||
}
|
||||
|
||||
const {
|
||||
createCurrentPluginStorageRepository,
|
||||
createCurrentPluginMigrationRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
|
||||
const kvKeys =
|
||||
@@ -164,6 +185,8 @@ export async function removePluginData(
|
||||
const migrations =
|
||||
await createCurrentPluginMigrationRepository().deleteByPlugin(pluginId);
|
||||
await createCurrentPluginPermissionGrantRepository().deleteByPlugin(pluginId);
|
||||
// Settings and the plugin's encrypted secrets go with it.
|
||||
await createCurrentPluginSettingsRepository().deleteByPlugin(pluginId);
|
||||
|
||||
forgetTables(pluginId);
|
||||
|
||||
@@ -174,3 +197,53 @@ export async function removePluginData(
|
||||
|
||||
return { tables: dropped, kvKeys, migrations };
|
||||
}
|
||||
|
||||
/**
|
||||
* The tables in `all` that belong to this plugin: its prefix, minus any that
|
||||
* sit under a longer prefix another plugin owns.
|
||||
*/
|
||||
export function ownedTableNames(
|
||||
pluginId: string,
|
||||
all: string[],
|
||||
knownPluginIds: string[],
|
||||
): string[] {
|
||||
const prefix = tablePrefix(pluginId);
|
||||
const longer = knownPluginIds
|
||||
.filter((id) => id !== pluginId)
|
||||
.map((id) => tablePrefix(id))
|
||||
.filter((other) => other.startsWith(prefix));
|
||||
return all.filter(
|
||||
(table) =>
|
||||
table.startsWith(prefix) &&
|
||||
!longer.some((other) => table.startsWith(other)),
|
||||
);
|
||||
}
|
||||
|
||||
function dropTableByName(dialect: DatabaseDialect, table: string): string {
|
||||
const quoted = dialect === "mysql" ? "`" + table + "`" : `"${table}"`;
|
||||
return `DROP TABLE IF EXISTS ${quoted};`;
|
||||
}
|
||||
|
||||
async function listDatabaseTables(dialect: DatabaseDialect): Promise<string[]> {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const db = getDb() as unknown as {
|
||||
all?: (query: unknown) => Promise<Array<Record<string, unknown>>>;
|
||||
execute?: (query: unknown) => Promise<unknown>;
|
||||
};
|
||||
if (dialect === "sqlite") {
|
||||
const rows = await db.all!(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table'`,
|
||||
);
|
||||
return rows.map((row) => String(row.name));
|
||||
}
|
||||
const query =
|
||||
dialect === "postgres"
|
||||
? sql`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()`
|
||||
: sql`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE()`;
|
||||
const result = await db.execute!(query);
|
||||
// node-postgres answers { rows }, mysql2 answers [rows, fields].
|
||||
const rows = (
|
||||
Array.isArray(result) ? result[0] : (result as { rows?: unknown[] }).rows
|
||||
) as Array<Record<string, unknown>>;
|
||||
return rows.map((row) => String(row.name ?? row.NAME));
|
||||
}
|
||||
@@ -38,11 +38,19 @@ const DEFAULT_BODY_LIMIT = "2mb";
|
||||
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
|
||||
const routers = new Map<string, Router>();
|
||||
|
||||
/** Declared public paths by plugin id, for the admin plugin details. */
|
||||
const publicRoutes = new Map<string, string[]>();
|
||||
|
||||
export function getPluginPublicHttpRoutes(pluginId: string): string[] {
|
||||
return [...(publicRoutes.get(pluginId) ?? [])];
|
||||
}
|
||||
|
||||
export function getPluginRouter(pluginId: string): Router | undefined {
|
||||
return routers.get(pluginId);
|
||||
}
|
||||
|
||||
export function unregisterPluginHttp(pluginId: string): void {
|
||||
publicRoutes.delete(pluginId);
|
||||
if (!routers.delete(pluginId)) return;
|
||||
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
|
||||
operation: "plugin_http_unmount",
|
||||
@@ -63,10 +71,27 @@ export function setPluginEnabledCheck(check: IsEnabled): void {
|
||||
isPluginEnabled = check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a plugin with this id is installed at all, running or not. A
|
||||
* disabled plugin has no router or socket routes left, so this is what tells
|
||||
* "disabled" (503) apart from "no such plugin" (404).
|
||||
*/
|
||||
let isInstalled: IsEnabled = () => false;
|
||||
|
||||
export function setPluginInstalledCheck(check: IsEnabled): void {
|
||||
isInstalled = check;
|
||||
}
|
||||
|
||||
export function isPluginInstalled(pluginId: string): boolean {
|
||||
return isInstalled(pluginId);
|
||||
}
|
||||
|
||||
/** Test seam, so one test's stub does not leak into the next. */
|
||||
export function resetPluginHttp(): void {
|
||||
routers.clear();
|
||||
publicRoutes.clear();
|
||||
isPluginEnabled = () => true;
|
||||
isInstalled = () => false;
|
||||
}
|
||||
|
||||
function normalizePublicPath(path: string): string {
|
||||
@@ -130,6 +155,7 @@ export function createPluginRouter({
|
||||
normalizePublicPath(path),
|
||||
);
|
||||
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
|
||||
publicRoutes.set(pluginId, declaredPublic);
|
||||
|
||||
if (declaredPublic.length > 0) {
|
||||
// Audited once at registration: the set of unauthenticated routes a plugin
|
||||
@@ -225,7 +251,20 @@ export function createPluginRouter({
|
||||
_next: NextFunction,
|
||||
): void => {
|
||||
void _next;
|
||||
reportError(error);
|
||||
const status = clientErrorStatus(error);
|
||||
const authenticated = !!(req as Request & { userId?: string }).userId;
|
||||
// A bad body or a 4xx is the caller's fault, and an anonymous caller
|
||||
// must never be able to spend the plugin's error budget: five junk
|
||||
// requests to a public route would otherwise switch the plugin off.
|
||||
if (status === null && authenticated) reportError(error);
|
||||
if (status !== null) {
|
||||
if (!res.headersSent) {
|
||||
res.status(status).json({ error: "Invalid request" });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
pluginLogger.error(
|
||||
`Plugin ${pluginId} route ${req.method} ${req.path} failed`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
@@ -248,6 +287,16 @@ export function createPluginRouter({
|
||||
return inner;
|
||||
}
|
||||
|
||||
/** The status of an error that is the caller's fault, or null. */
|
||||
function clientErrorStatus(error: unknown): number | null {
|
||||
const status =
|
||||
(error as { status?: unknown; statusCode?: unknown } | null)?.status ??
|
||||
(error as { statusCode?: unknown } | null)?.statusCode;
|
||||
return typeof status === "number" && status >= 400 && status < 500
|
||||
? status
|
||||
: null;
|
||||
}
|
||||
|
||||
async function writePublicRouteAudit(
|
||||
manifest: PluginManifest,
|
||||
paths: string[],
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
|
||||
import {
|
||||
setPluginEnabledCheck,
|
||||
setPluginInstalledCheck,
|
||||
unregisterPluginHttp,
|
||||
} from "./http.js";
|
||||
import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
||||
import type { PluginPermissionContribution } from "./manifest.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
@@ -29,6 +33,7 @@ export function getPluginRuntime(): { loader: PluginLoader } {
|
||||
setPluginEnabledCheck(
|
||||
(pluginId) => loader?.get(pluginId)?.state === "active",
|
||||
);
|
||||
setPluginInstalledCheck((pluginId) => !!loader?.get(pluginId));
|
||||
// Lets a host whose auth type belongs to a disabled plugin name it.
|
||||
setSshAuthTypeOwnerSource(() =>
|
||||
(loader?.list() ?? []).flatMap((plugin) =>
|
||||
@@ -256,6 +261,24 @@ async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
|
||||
].filter((id) => id !== plugin.id),
|
||||
);
|
||||
|
||||
// The group is the plugin id, so a plugin named after a core group would
|
||||
// register that group's permissions. The manifest validator refuses the
|
||||
// id too; this holds for a manifest that skipped it.
|
||||
if (coreGroups.has(plugin.id)) {
|
||||
recordConflict({
|
||||
kind: "permission",
|
||||
pluginId: plugin.id,
|
||||
heldBy: "core",
|
||||
name: plugin.id,
|
||||
});
|
||||
pluginLogger.error(
|
||||
`Ignoring every ${plugin.id} permission: its id is a core permission group`,
|
||||
undefined,
|
||||
{ operation: "plugin_permissions" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = declared.filter((permission) => {
|
||||
const head = permission.name.split(".")[0];
|
||||
if (coreGroups.has(head) || otherPluginIds.has(head)) {
|
||||
@@ -379,16 +402,18 @@ async function applyRoleDefaults(
|
||||
return !coveredBy(current as string[], permission);
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
await roleRepository.updateRole(role.id, {
|
||||
permissions: JSON.stringify([...(current as string[]), ...missing]),
|
||||
});
|
||||
}
|
||||
// Only once the role really has them, so a failed update is retried
|
||||
// on the next boot instead of being marked done.
|
||||
for (const permission of permissions) {
|
||||
recorded.push({ roleName, permission });
|
||||
}
|
||||
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
await roleRepository.updateRole(role.id, {
|
||||
permissions: JSON.stringify([...(current as string[]), ...missing]),
|
||||
});
|
||||
|
||||
for (const memberId of await roleRepository.listRoleUserIds(role.id)) {
|
||||
PermissionManager.getInstance().invalidateUserPermissionCache(memberId);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from "./ctx.js";
|
||||
import { resolveRequirements } from "./service-registry.js";
|
||||
import { resolveSecretRequirements } from "./secret-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { tablePrefix } from "@termix/plugin-sdk/db";
|
||||
|
||||
export type PluginState =
|
||||
| "loaded"
|
||||
@@ -119,6 +121,27 @@ export class PluginLoader {
|
||||
`Plugin ${manifest.id} backend entry ${entry} is missing`,
|
||||
);
|
||||
}
|
||||
// A symlink can point anywhere, so the real path has to stay inside too.
|
||||
if (!isRealPathInside(dir, entry)) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} backend entry resolves outside the plugin directory`,
|
||||
);
|
||||
}
|
||||
|
||||
const overlap = [...this.plugins.values()].find((other) =>
|
||||
prefixesOverlap(other.id, manifest.id),
|
||||
);
|
||||
if (overlap) {
|
||||
recordConflict({
|
||||
kind: "id",
|
||||
pluginId: manifest.id,
|
||||
heldBy: overlap.id,
|
||||
name: tablePrefix(manifest.id),
|
||||
});
|
||||
throw new Error(
|
||||
`Plugin id "${manifest.id}" overlaps "${overlap.id}": their table names would share the prefix ${tablePrefix(overlap.id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
id: manifest.id,
|
||||
@@ -144,12 +167,15 @@ export class PluginLoader {
|
||||
* dropping a folder with a bundled plugin's id into the data directory is visibly
|
||||
* refused instead of quietly ignored.
|
||||
*
|
||||
* The check is on the parsed manifest id, not the directory name, because a
|
||||
* directory can be named anything.
|
||||
* The check is on the directory name, which load() then requires to equal
|
||||
* the manifest id, and it covers bundled folders that failed to load too.
|
||||
*/
|
||||
async loadAll(): Promise<LoadedPlugin[]> {
|
||||
this.plugins.clear();
|
||||
const loaded: LoadedPlugin[] = [];
|
||||
// Every bundled folder name, loaded or not: a bundled plugin that fails
|
||||
// to load must not leave its id free for a user plugin to take.
|
||||
const bundledIds = new Set<string>();
|
||||
|
||||
const roots: Array<{ root: string; source: PluginSource }> = [
|
||||
{ root: getBundledPluginsDir(), source: "bundled" },
|
||||
@@ -160,14 +186,18 @@ export class PluginLoader {
|
||||
if (!fs.existsSync(root)) continue;
|
||||
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
if (source === "bundled") {
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) bundledIds.add(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dir = path.join(root, entry.name);
|
||||
try {
|
||||
const existing = this.plugins.get(entry.name);
|
||||
if (existing && source === "user") {
|
||||
if (source === "user" && bundledIds.has(entry.name)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
|
||||
);
|
||||
@@ -343,7 +373,9 @@ export class PluginLoader {
|
||||
// than leave it half-running against a schema that is not there. A
|
||||
// throw here lands in the catch below, which fails this plugin only.
|
||||
const { migratePlugin } = await import("./data.js");
|
||||
const applied = await migratePlugin(plugin.id, plugin.dir);
|
||||
const applied = await migratePlugin(plugin.id, plugin.dir, {
|
||||
bundled: plugin.source === "bundled",
|
||||
});
|
||||
// A new table may be where core data is waiting to move.
|
||||
if (applied.length > 0) {
|
||||
const { runPluginDataMoves } =
|
||||
@@ -577,3 +609,28 @@ function pathToFileUrlString(filePath: string): string {
|
||||
const resolved = path.resolve(filePath).replace(/\\/g, "/");
|
||||
return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when one id's table prefix starts with the other's: "foo" owns
|
||||
* p_foo_*, which already covers every table "foo-bar" (p_foo_bar_*) creates.
|
||||
*/
|
||||
export function prefixesOverlap(a: string, b: string): boolean {
|
||||
if (a === b) return false;
|
||||
const left = tablePrefix(a);
|
||||
const right = tablePrefix(b);
|
||||
return left.startsWith(right) || right.startsWith(left);
|
||||
}
|
||||
|
||||
/** Whether `target` really lives under `root`, following symlinks. */
|
||||
export function isRealPathInside(root: string, target: string): boolean {
|
||||
try {
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const relative = path.relative(realRoot, fs.realpathSync(target));
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,11 @@ import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import type { DatabaseDialect } from "../database/db/dialect.js";
|
||||
import { tablePrefix, LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import {
|
||||
findUnownedTableWrites,
|
||||
splitStatements,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
|
||||
// The splitter is shared with createTestDb in the SDK, so tests apply a
|
||||
// migration exactly the way this runner does.
|
||||
@@ -68,6 +71,15 @@ export function readMigrations(
|
||||
): PluginMigration[] {
|
||||
const dir = migrationsDir(pluginDir, dialect);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
// A symlinked folder or file could feed in SQL from anywhere on disk.
|
||||
const realRoot = fs.realpathSync(pluginDir);
|
||||
const inside = (target: string) => {
|
||||
const relative = path.relative(realRoot, fs.realpathSync(target));
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
};
|
||||
if (!inside(dir)) {
|
||||
throw new Error("The migrations folder resolves outside the plugin");
|
||||
}
|
||||
|
||||
const migrations: PluginMigration[] = [];
|
||||
for (const file of fs.readdirSync(dir).sort()) {
|
||||
@@ -80,6 +92,9 @@ export function readMigrations(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!inside(path.join(dir, file))) {
|
||||
throw new Error(`Migration "${file}" resolves outside the plugin`);
|
||||
}
|
||||
const contents = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
migrations.push({
|
||||
id: file.replace(/\.sql$/, ""),
|
||||
@@ -95,41 +110,39 @@ export function readMigrations(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables a migration may create or alter.
|
||||
* Checks that every table a migration writes to belongs to this plugin.
|
||||
*
|
||||
* A plugin owns the p_<id>_ namespace and nothing else. Enforced here as well
|
||||
* as in the CLI, because a plugin installed from a tarball never ran the CLI.
|
||||
*/
|
||||
const CREATE_OR_ALTER =
|
||||
/\b(?:CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+([`"']?)([a-zA-Z0-9_]+)\1/gi;
|
||||
|
||||
/**
|
||||
* Checks that every table a migration touches belongs to this plugin.
|
||||
*
|
||||
* A legacy table it is allowed to adopt is permitted too, because adoption
|
||||
* renames it into the plugin's namespace in the same migration.
|
||||
* A legacy core table is allowed only for the bundled plugin that adopts it:
|
||||
* a user plugin with the same id gets no exemption.
|
||||
*/
|
||||
export function assertOwnedTables(
|
||||
pluginId: string,
|
||||
statements: string[],
|
||||
sql: string,
|
||||
options: { bundled?: boolean } = {},
|
||||
): void {
|
||||
const prefix = tablePrefix(pluginId);
|
||||
|
||||
for (const statement of statements) {
|
||||
CREATE_OR_ALTER.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CREATE_OR_ALTER.exec(statement)) !== null) {
|
||||
const table = match[2];
|
||||
if (table.startsWith(prefix)) continue;
|
||||
if (LEGACY_TABLE_OWNERS[table] === pluginId) continue;
|
||||
|
||||
const owner = LEGACY_TABLE_OWNERS[table];
|
||||
throw new Error(
|
||||
owner
|
||||
? `Plugin "${pluginId}" may not touch "${table}": it belongs to the "${owner}" plugin.`
|
||||
: `Plugin "${pluginId}" may only create or alter tables prefixed "${prefix}", not "${table}".`,
|
||||
);
|
||||
}
|
||||
const legacy = new Set(
|
||||
options.bundled
|
||||
? Object.entries(LEGACY_TABLE_OWNERS)
|
||||
.filter(([, owner]) => owner === pluginId)
|
||||
.map(([table]) => table)
|
||||
: [],
|
||||
);
|
||||
const problems = findUnownedTableWrites(pluginId, sql, legacy).map(
|
||||
(problem) => {
|
||||
// Name the owner when the table is another plugin's legacy table.
|
||||
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
|
||||
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
|
||||
return owner && owner !== pluginId
|
||||
? `may not touch "${table}": it belongs to the "${owner}" plugin`
|
||||
: problem;
|
||||
},
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" migration refused: ${problems.join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +171,7 @@ export async function applyPluginMigrations(
|
||||
pluginId: string,
|
||||
pluginDir: string,
|
||||
runner: MigrationRunner,
|
||||
options: { bundled?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const migrations = readMigrations(pluginDir, runner.dialect);
|
||||
if (migrations.length === 0) return [];
|
||||
@@ -185,8 +199,8 @@ export async function applyPluginMigrations(
|
||||
|
||||
const run = async () => {
|
||||
for (const migration of pending) {
|
||||
assertOwnedTables(pluginId, migration.sql, options);
|
||||
const statements = splitStatements(migration.sql);
|
||||
assertOwnedTables(pluginId, statements);
|
||||
await runner.execute(statements);
|
||||
await runner.record(pluginId, migration);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
*
|
||||
* The cache is invalidated explicitly on every grant change rather than by
|
||||
* TTL: a revoke that takes effect a minute later is not a revoke.
|
||||
*
|
||||
* Every refusal is audited here, so a guard added anywhere in the runtime
|
||||
* leaves a trail without each call site remembering to write one.
|
||||
*/
|
||||
|
||||
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
const cache = new Map<string, Set<string>>();
|
||||
|
||||
@@ -43,12 +47,81 @@ export async function hasCapability(
|
||||
return (await loadGrants(pluginId)).has(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* For hooks ssh2 calls synchronously. Answers from the cache only and fails
|
||||
* closed when the grants are not loaded yet, starting the load for next time.
|
||||
*/
|
||||
export function hasCachedCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
): boolean {
|
||||
if (!declared.includes(capability)) return false;
|
||||
const cached = cache.get(pluginId);
|
||||
if (!cached) {
|
||||
void loadGrants(pluginId).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
return cached.has(capability);
|
||||
}
|
||||
|
||||
/** Loads a plugin's grants ahead of a synchronous hasCachedCapability. */
|
||||
export function warmPluginGrants(pluginId: string): void {
|
||||
void loadGrants(pluginId).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws PluginCapabilityError unless granted, after writing the refusal's
|
||||
* audit line. `action` names the call ("kv_set"), so the line says what was
|
||||
* refused and not only which capability was missing.
|
||||
*/
|
||||
export async function assertCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
action = "capability_refused",
|
||||
): Promise<void> {
|
||||
if (!(await hasCapability(pluginId, capability, declared))) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
if (await hasCapability(pluginId, capability, declared)) return;
|
||||
const error = new PluginCapabilityError(pluginId, capability);
|
||||
await auditRefusal(pluginId, capability, action, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* The error for a capability a sync call never declared, with its audit
|
||||
* line written in the background.
|
||||
*/
|
||||
export function capabilityRefused(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
action = "capability_refused",
|
||||
): PluginCapabilityError {
|
||||
const error = new PluginCapabilityError(pluginId, capability);
|
||||
void auditRefusal(pluginId, capability, action, error);
|
||||
return error;
|
||||
}
|
||||
|
||||
async function auditRefusal(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
action: string,
|
||||
error: Error,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
username: `plugin:${pluginId}`,
|
||||
action: `plugin_${action}`,
|
||||
resourceType: "plugin",
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: `refused: ${capability}`,
|
||||
success: false,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never change what the caller sees.
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ export function resolvePermission(
|
||||
if (!head) return permission;
|
||||
|
||||
if (head === manifest.id) return permission;
|
||||
// A name this plugin declares is its own, even if another plugin's id
|
||||
// happens to match its first segment.
|
||||
if (
|
||||
(manifest.contributes?.permissions ?? []).some(
|
||||
(declared) => declared.name === permission,
|
||||
)
|
||||
) {
|
||||
return `${manifest.id}.${permission}`;
|
||||
}
|
||||
if (coreGroups().has(head)) return permission;
|
||||
|
||||
// Another plugin's registered group, which is the cross-plugin check.
|
||||
|
||||
@@ -106,7 +106,7 @@ export class PluginSecretActorError extends Error {
|
||||
constructor(pluginId: string, key: string) {
|
||||
super(
|
||||
`A read of "${pluginId}:${key}" has no acting user. Pass one with ` +
|
||||
`ctx.secrets.forUser(userId).getShared(...).`,
|
||||
`ctx.secrets.getShared(pluginId, key, { userId }) or ctx.asUser.`,
|
||||
);
|
||||
this.name = "PluginSecretActorError";
|
||||
}
|
||||
|
||||
@@ -309,6 +309,8 @@ function recordUnsatisfied(
|
||||
export interface ServiceCallContext {
|
||||
/** Resolves the user this call acts as. */
|
||||
resolveUserId: () => string | undefined;
|
||||
/** Called when a handle is bound to a named user with asUser(). */
|
||||
nameUser?: (userId: string) => void;
|
||||
/** Checks a role permission for a user. */
|
||||
hasPermission: (userId: string, permission: string) => Promise<boolean>;
|
||||
/**
|
||||
@@ -359,7 +361,13 @@ export function createServiceHandle<T extends object>(
|
||||
createServiceHandle<T>(
|
||||
service,
|
||||
consumerPluginId,
|
||||
{ ...context, resolveUserId: () => userId },
|
||||
{
|
||||
...context,
|
||||
resolveUserId: () => {
|
||||
context.nameUser?.(userId);
|
||||
return userId;
|
||||
},
|
||||
},
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { pluginLogger } from "../utils/logger.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
import { isPluginInstalled } from "./http.js";
|
||||
|
||||
const PLUGIN_WS_PREFIX = "/plugin-ws/";
|
||||
|
||||
@@ -138,8 +139,13 @@ async function handlePluginUpgrade(
|
||||
const route = routes.get(key(parsed.pluginId, parsed.path));
|
||||
if (!route) {
|
||||
// Claimed by the prefix but nothing is listening: answering here is more
|
||||
// honest than leaving the socket hanging for a plugin that is disabled.
|
||||
reject(socket, 404, "Not Found");
|
||||
// honest than leaving the socket hanging. A disabled plugin has no routes
|
||||
// left, so an installed id means "off", not "missing".
|
||||
if (isPluginInstalled(parsed.pluginId)) {
|
||||
reject(socket, 503, "Service Unavailable");
|
||||
} else {
|
||||
reject(socket, 404, "Not Found");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -164,11 +170,14 @@ async function handlePluginUpgrade(
|
||||
userId = (await verifyToken(request)) ?? "";
|
||||
}
|
||||
|
||||
// A guest on a public socket has no actor at all, the same as a public
|
||||
// HTTP route, so nothing downstream mistakes a placeholder for a user.
|
||||
const asCaller = (fn: () => void) =>
|
||||
userId ? runAsActor(userId, "request", fn) : fn();
|
||||
|
||||
if (route.rawHandler) {
|
||||
const rawHandler = route.rawHandler;
|
||||
runAsActor(userId || "anonymous", "request", () =>
|
||||
rawHandler(request, socket, head, userId),
|
||||
);
|
||||
asCaller(() => rawHandler(request, socket, head, userId));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -195,7 +204,7 @@ async function handlePluginUpgrade(
|
||||
ws.close(1011, "No handler");
|
||||
return;
|
||||
}
|
||||
runAsActor(userId || "anonymous", "request", () => {
|
||||
asCaller(() => {
|
||||
void Promise.resolve(handler({ ...connection, socket: ws })).catch(
|
||||
(error) => {
|
||||
pluginLogger.error(
|
||||
@@ -260,6 +269,7 @@ export function registerPluginWsRoute(
|
||||
declared,
|
||||
handler,
|
||||
});
|
||||
if (options.public) auditPublicSocket(pluginId, normalized);
|
||||
servers.set(routeKey, new WebSocketServer({ noServer: true }));
|
||||
|
||||
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
|
||||
@@ -292,6 +302,7 @@ export function registerPluginWsUpgrade(
|
||||
declared,
|
||||
rawHandler: handler,
|
||||
});
|
||||
if (options.public) auditPublicSocket(pluginId, normalized);
|
||||
|
||||
pluginLogger.info(`Mounted raw upgrade /plugin-ws/${pluginId}${normalized}`, {
|
||||
operation: "plugin_ws_mount",
|
||||
@@ -331,6 +342,35 @@ export function getRegisteredWsRoutes(): string[] {
|
||||
return [...routes.keys()];
|
||||
}
|
||||
|
||||
/** The socket paths a plugin serves without core's login check. */
|
||||
export function getPluginPublicWsRoutes(pluginId: string): string[] {
|
||||
return [...routes.values()]
|
||||
.filter((route) => route.pluginId === pluginId && route.options.public)
|
||||
.map((route) => route.path);
|
||||
}
|
||||
|
||||
/** Same record the HTTP side writes for its public routes. */
|
||||
function auditPublicSocket(pluginId: string, path: string): void {
|
||||
pluginLogger.warn(
|
||||
`Plugin ${pluginId} serves /plugin-ws/${pluginId}${path} without core authentication`,
|
||||
{ operation: "plugin_ws_public" },
|
||||
);
|
||||
void import("../utils/audit-logger.js")
|
||||
.then(({ logAudit }) =>
|
||||
logAudit({
|
||||
userId: "system",
|
||||
username: `plugin:${pluginId}`,
|
||||
action: "plugin_ws_public_route",
|
||||
resourceType: "plugin",
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: `unauthenticated socket: ${path}`,
|
||||
success: true,
|
||||
}),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginWebSockets(): void {
|
||||
for (const routeKey of [...routes.keys()]) {
|
||||
|
||||
Reference in new issue
Block a user